diff --git a/NEMbox/cli.py b/NEMbox/cli.py index babf675..98d839a 100644 --- a/NEMbox/cli.py +++ b/NEMbox/cli.py @@ -5,9 +5,12 @@ import curses import io import json +import os import sys from typing import Any +import requests + from . import __version__ from .api import NetEase from .config import Config @@ -611,15 +614,195 @@ def _rpc( ) -def cmd_play(ctx: CliContext, ns: argparse.Namespace) -> int: - params: dict[str, Any] = {} - if ns.id is not None: - params["id"] = ns.id - if ns.playlist is not None: - params["playlist"] = ns.playlist - if ns.index is not None: - params["index"] = ns.index - return _rpc(ctx, ns, "player.play", params, human_fn=_format_status) +def cmd_play(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: + if ns.id is not None or ns.playlist is not None or ns.index is not None: + params: dict[str, Any] = {} + if ns.id is not None: + params["id"] = ns.id + if ns.playlist is not None: + params["playlist"] = ns.playlist + if ns.index is not None: + params["index"] = ns.index + return _rpc(ctx, ns, "player.play", params, human_fn=_format_status) + + ids: list[int] = [] + if ns.artist is not None: + raw = api.artists(ns.artist) + if not raw: + return ctx.emit_err("api_error", f"歌手 {ns.artist} 没有歌曲") + limit = ns.limit if ns.limit is not None else 20 + ids = [s["id"] for s in raw[:limit] if s.get("id")] + if not ids: + return ctx.emit_err("api_error", f"无法获取歌手 {ns.artist} 的歌曲信息") + elif ns.album is not None: + raw = api.album(ns.album) + if not raw: + return ctx.emit_err("api_error", f"专辑 {ns.album} 的歌曲为空") + ids = [s["id"] for s in raw if s.get("id")] + if not ids: + return ctx.emit_err("api_error", f"无法获取专辑 {ns.album} 的歌曲信息") + elif ns.songs is not None: + ids = list(ns.songs) + else: + return _rpc(ctx, ns, "player.play", {}, human_fn=_format_status) + + if getattr(ns, "dry_run", False): + preview = [ + {"method": "queue.clear", "params": {}}, + {"method": "queue.add", "params": {"ids": ids}}, + {"method": "player.play", "params": {"index": 0}}, + ] + return ctx.emit_ok(preview, json.dumps(preview, ensure_ascii=False)) + + failed = _ensure_daemon(ctx, ns) + if failed is not None: + return failed + + for method, params in [ + ("queue.clear", {}), + ("queue.add", {"ids": ids}), + ]: + try: + resp = send_request(method, params) + except ConnectionError: + return ctx.emit_err( + "daemon_not_running", + "无法连接 daemon", + "musicbox daemon start", + exit_code=EXIT_DAEMON_NOT_RUNNING, + ) + if not resp.get("ok"): + err = resp.get("error", {}) + return ctx.emit_err( + err.get("type", "api_error"), + err.get("message", str(resp)), + exit_code=_ERROR_EXIT_CODES.get(err.get("type", ""), EXIT_GENERIC), + ) + return _rpc(ctx, ns, "player.play", {"index": 0}, human_fn=_format_status) + + +def cmd_download(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: + limit = getattr(ns, "limit", None) + if ns.artist is not None: + raw = api.artists(ns.artist) + effective_limit = limit if limit is not None else 20 + raw = raw[:effective_limit] + songs = api.dig_info(raw, "songs") or [] + source = "artist" + source_id = ns.artist + elif ns.album is not None: + raw = api.album(ns.album) + songs = api.dig_info(raw, "songs") or [] + source = "album" + source_id = ns.album + elif ns.songs is not None: + ids = list(ns.songs) + songs = api.dig_info([{"id": sid} for sid in ids], "songs") or [] + source = "songs" + source_id = ",".join(str(i) for i in ids) + elif ns.playlist is not None: + track_ids = api.playlist_songlist(ns.playlist) + if not track_ids: + return ctx.emit_err("api_error", f"歌单 {ns.playlist} 为空或不存在") + songs = api.dig_info(track_ids, "songs") or [] + source = "playlist" + source_id = ns.playlist + else: + return ctx.emit_err( + "invalid_args", + "请指定 --artist / --album / --playlist / --songs", + "musicbox download --artist 6452 --path ./music", + exit_code=EXIT_INVALID_ARGS, + ) + if not songs: + return ctx.emit_err("api_error", f"未找到可下载的歌曲 ({source}:{source_id})") + + dest = os.path.abspath(ns.path) if ns.path else os.path.abspath(".") + os.makedirs(dest, exist_ok=True) + + results = [] + ok_count = 0 + for s in songs: + song_id = s.get("song_id") + song_name = s.get("song_name", "unknown") + artist = s.get("artist", "unknown") + mp3_url = s.get("mp3_url", "") + if not mp3_url: + if ctx.json_mode: + results.append( + { + "song_id": song_id, + "song_name": song_name, + "ok": False, + "error": "无播放链接", + } + ) + else: + print(f" FAIL {song_name}: 无播放链接") + continue + ext = ".mp3" + if mp3_url.lower().endswith(".flac") or ".flac" in mp3_url: + ext = ".flac" + filename = f"{artist}-{song_name}{ext}" + for ch in '\\/:*?"<>|': + filename = filename.replace(ch, "_") + filepath = os.path.join(dest, filename) + try: + resp = requests.get(mp3_url, stream=True, timeout=30) + resp.raise_for_status() + with open(filepath, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + if ctx.json_mode: + results.append( + { + "song_id": song_id, + "song_name": song_name, + "artist": artist, + "ok": True, + "path": filepath, + } + ) + else: + print(f" OK {song_name} -> {filepath}") + ok_count += 1 + except Exception as e: + if ctx.json_mode: + results.append( + { + "song_id": song_id, + "song_name": song_name, + "ok": False, + "error": str(e), + } + ) + else: + print(f" FAIL {song_name}: {e}") + + if ok_count == 0: + return ctx.emit_err( + "download_failed", + "所有歌曲下载失败", + exit_code=EXIT_GENERIC, + ) + + if ctx.json_mode: + ok_total = sum(1 for r in results if r["ok"]) + parts = [f"下载完成: {ok_total}/{len(results)} 首"] + for r in results: + if r["ok"]: + parts.append(f" OK {r['song_name']} -> {r['path']}") + else: + parts.append(f" FAIL {r['song_name']}: {r.get('error')}") + human = "\n".join(parts) + return ctx.emit_ok( + results, + human, + notice=_update_notice() if ctx.json_mode else None, + ) + else: + print(f"下载完成: {ok_count}/{len(songs)} 首") + return EXIT_OK def cmd_simple_control(ctx: CliContext, ns: argparse.Namespace, method: str) -> int: @@ -834,6 +1017,34 @@ def _build_parser() -> MusicboxArgumentParser: p_search.add_argument("--limit", type=int, default=20, help="返回条数上限") p_search.set_defaults(handler="search") + # artist + p_artist = sub.add_parser( + "artist", + help="歌手热门歌曲列表", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " musicbox artist 6452\n" + " musicbox artist 6452 --limit 10 --json" + ), + ) + _add_common_flags(p_artist) + p_artist.add_argument("id", type=int, help="歌手 ID") + p_artist.add_argument("--limit", type=int, default=20, help="获取数量上限") + p_artist.set_defaults(handler="artist") + + # album + p_album = sub.add_parser( + "album", + help="专辑歌曲列表", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=("Examples:\n musicbox album 32311\n musicbox album 32311 --json"), + ) + _add_common_flags(p_album) + p_album.add_argument("id", type=int, help="专辑 ID") + p_album.add_argument("--limit", type=int, default=None, help="获取数量上限") + p_album.set_defaults(handler="album") + # song p_song = sub.add_parser("song", help="歌曲信息与播放链接") song_sub = p_song.add_subparsers(dest="song_cmd", metavar="SUBCOMMAND") @@ -1033,9 +1244,44 @@ def _build_parser() -> MusicboxArgumentParser: def _build_control_parsers(sub: Any) -> None: # play + p_download = sub.add_parser( + "download", + help="下载歌曲/专辑/歌单到本地目录", + description="下载指定歌曲/专辑/歌单到本地文件夹", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " musicbox download --artist 6452 --limit 20 --path ./music\n" + " musicbox download --album 32311\n" + " musicbox download --songs 33894312 28258988\n" + " musicbox download --playlist 12345" + ), + ) + _add_common_flags(p_download) + src = p_download.add_mutually_exclusive_group(required=True) + src.add_argument( + "--artist", type=int, default=None, help="歌手 ID,下载该歌手热门歌曲" + ) + src.add_argument( + "--album", type=int, default=None, help="专辑 ID,下载该专辑所有歌曲" + ) + src.add_argument( + "--songs", type=int, nargs="+", default=None, help="歌曲 ID 列表,下载指定歌曲" + ) + src.add_argument( + "--playlist", type=int, default=None, help="歌单 ID,下载该歌单中所有歌曲" + ) + p_download.add_argument( + "--path", type=str, default=".", help="目标目录(默认为当前目录)" + ) + p_download.add_argument( + "--limit", type=int, default=None, help="获取数量上限(仅对 --artist 生效)" + ) + p_download.set_defaults(handler="download") + p_play = sub.add_parser( "play", - help="播放歌曲/歌单,或恢复当前播放(经 daemon)", + help="播放歌曲/专辑/歌单,或恢复当前播放(经 daemon)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" @@ -1046,9 +1292,22 @@ def _build_control_parsers(sub: Any) -> None: ), ) _add_control_flags(p_play) - p_play.add_argument("--id", type=int, default=None, help="按歌曲 ID 播放") - p_play.add_argument("--playlist", type=int, default=None, help="按歌单 ID 播放") - p_play.add_argument("--index", type=int, default=None, help="播放队列中第 n 首") + src = p_play.add_mutually_exclusive_group() + src.add_argument("--id", type=int, default=None, help="按歌曲 ID 播放") + src.add_argument("--playlist", type=int, default=None, help="按歌单 ID 播放") + src.add_argument("--index", type=int, default=None, help="播放队列中第 n 首") + src.add_argument( + "--artist", type=int, default=None, help="歌手 ID,播放该歌手热门歌曲" + ) + src.add_argument( + "--album", type=int, default=None, help="专辑 ID,播放该专辑所有歌曲" + ) + src.add_argument( + "--songs", type=int, nargs="+", default=None, help="歌曲 ID 列表,播放指定歌曲" + ) + p_play.add_argument( + "--limit", type=int, default=None, help="获取数量上限(仅对 --artist 生效)" + ) p_play.set_defaults(handler="play") # simple controls @@ -1217,6 +1476,31 @@ def _build_control_parsers(sub: Any) -> None: p_daemon.set_defaults(handler="daemon") +def cmd_artist(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: + raw = api.artists(ns.id) + if not raw: + return ctx.emit_err("api_error", f"歌手 {ns.id} 没有歌曲") + limit = ns.limit if ns.limit is not None else 20 + songs = api.dig_info(raw[:limit], "songs") or [] + if not songs: + return ctx.emit_err("api_error", f"无法获取歌手 {ns.id} 的歌曲信息") + human = "\n".join(_format_song_line(s) for s in songs) + return ctx.emit_ok(songs, human) + + +def cmd_album(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: + raw = api.album(ns.id) + if not raw: + return ctx.emit_err("api_error", f"专辑 {ns.id} 的歌曲为空") + if ns.limit is not None: + raw = raw[: ns.limit] + songs = api.dig_info(raw, "songs") or [] + if not songs: + return ctx.emit_err("api_error", f"无法获取专辑 {ns.id} 的歌曲信息") + human = "\n".join(_format_song_line(s) for s in songs) + return ctx.emit_ok(songs, human) + + def dispatch(api: NetEase, ns: argparse.Namespace) -> int: ctx = _ctx_from_ns(ns) handler = getattr(ns, "handler", None) @@ -1243,7 +1527,10 @@ def dispatch(api: NetEase, ns: argparse.Namespace) -> int: "auth_logout": lambda: cmd_auth_logout(api, ctx, ns), "config_get": lambda: cmd_config_get(ctx, ns), "config_list": lambda: cmd_config_list(ctx, ns), - "play": lambda: cmd_play(ctx, ns), + "play": lambda: cmd_play(api, ctx, ns), + "download": lambda: cmd_download(api, ctx, ns), + "artist": lambda: cmd_artist(api, ctx, ns), + "album": lambda: cmd_album(api, ctx, ns), "pause": lambda: cmd_simple_control(ctx, ns, "player.pause"), "resume": lambda: cmd_simple_control(ctx, ns, "player.resume"), "toggle": lambda: cmd_simple_control(ctx, ns, "player.toggle"), diff --git a/NEMbox/player.py b/NEMbox/player.py index d16a633..b6477e2 100644 --- a/NEMbox/player.py +++ b/NEMbox/player.py @@ -647,6 +647,9 @@ def run_mpg123(self, on_exit, url, expires=-1, get_time=-1): self.stop() elif copyright_issue_flag: self._advance_on_playback_failure() + elif frame_cnt == 0: + self.stop() + self.playing_flag = False else: self.next() diff --git a/README.md b/README.md index 2d58c5d..09a40ab 100644 --- a/README.md +++ b/README.md @@ -128,12 +128,18 @@ MusicBox 支持命令行和 AI Agent 调用:搜索歌曲、获取播放链接 ```bash musicbox search 邓丽君 --type song --json +musicbox artist 6452 --limit 10 --json +musicbox album 32311 --json musicbox song url 1847408145 --quality lossless --quiet musicbox play --id 1847408145 --json +musicbox play --artist 6452 --limit 10 +musicbox play --album 32311 +musicbox play --songs 33894312 28258988 musicbox pause --json musicbox status --json musicbox queue list --json musicbox auth login --no-wait --json +musicbox download --playlist 3778678 --path ./music --json ``` 安装 Agent Skill 后,可直接让 Codex、Claude Code、Cursor 等 Agent 操作 MusicBox: diff --git a/skills/musicbox/SKILL.md b/skills/musicbox/SKILL.md index b0fc308..9aafa0e 100644 --- a/skills/musicbox/SKILL.md +++ b/skills/musicbox/SKILL.md @@ -13,13 +13,13 @@ description: Use when the user wants to play/pause/skip music, control volume, s - 通过 `musicbox --json` 操作,**绝不**向 curses TUI 模拟按键。 - 重要动作后调 `musicbox status --json` **读回真实状态**再回复用户,不要"发完命令就假设成功"。 - `play` / `next` / `prev` 的即时返回值常滞后(先 `stopped` 或旧进度);**sleep 1–2 秒后再 `status`** 再下结论。 -- 数据类命令(search/playlist/toplist/...)无状态,直接调,**不需要** daemon。 +- 数据类命令(search/artist/album/playlist/toplist/...)无状态,直接调,**不需要** daemon。 ## 命令速查 | 类别 | 命令 | | --- | --- | -| 播放控制 | `play [--id \|--playlist \|--index ]` / `pause` / `resume` / `toggle` / `stop` | +| 播放控制 | `play [--id \|--playlist \|--index \|--artist \|--album \|--songs ]` / `pause` / `resume` / `toggle` / `stop` | | 切歌/进度 | `next [n]` / `prev [n]` / `seek <秒\|+n\|-n>` | | 音量/模式 | `volume <0-100\|+n\|-n>` / `mode ` | | 状态/歌词 | `status` / `lyrics --current` | @@ -29,6 +29,8 @@ description: Use when the user wants to play/pause/skip music, control volume, s | 歌曲/歌单 | `song info ` / `song url [--quality lossless]` / `playlist show ` | | 榜单/推荐 | `toplist [--index n]` / `recommend songs\|playlists`(需登录) / `fm`(需登录) | | 评论/喜爱 | `comments ` / `like `(需登录) | +| 查询歌手/专辑 | `artist ` / `album ` | +| 下载 | `download --artist/--album/--songs/--playlist` | | 认证/配置 | `auth status\|login\|logout` / `config get ` / `config list` | ## 任务配方 @@ -73,6 +75,43 @@ musicbox status --json 切歌后用 `queue_index` / `queue_size` 确认当前第几首。 + +### 搜歌手并播放热门歌曲 + +```bash +musicbox search <关键词> --type artist --json # 取 data[0].id +musicbox artist --json # 查看热门歌曲 +musicbox play --artist --limit 20 --json # 播放前 20 首 +musicbox status --json +``` + +### 搜专辑并播放 + +```bash +musicbox search <关键词> --type album --json # 取 data[0].id +musicbox album --json # 查看专辑歌曲列表 +musicbox play --album --json # 播放整张专辑 +musicbox status --json +``` + +### 播放多首指定歌曲 + +```bash +musicbox play --songs --json # 清队列 + 添加歌曲 + 播放第一首 +musicbox queue list --json # 确认队列内容 +musicbox status --json +``` + +### 下载歌曲 + +```bash +musicbox download --playlist --path ./music --json +musicbox download --artist --limit 20 --path ./music --json +musicbox download --album --path ./music --json +musicbox download --songs --path ./music --json +``` + +每首歌曲下载为 `-{ext}`,扩展名取决于音源(`.mp3` 或 `.flac`),结果输出 JSON 数组,每项包含 `ok`/错误信息。 ## 输出约定 - Agent 调用时**始终加 `--json`**,解析 stdout 的 `{ok, data}` 信封。 diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e4af26..c54b73c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -20,14 +20,57 @@ def search(self, keywords, stype=1, offset=0, total="true", limit=50): ] } + def artists(self, artist_id): + return [ + { + "id": 33894312, + "name": "邂逅", + "ar": [{"name": "周杰伦"}], + "al": {"name": "范特西", "id": 1}, + "dt": 273000, + }, + { + "id": 1847408145, + "name": "不能说的秘密", + "ar": [{"name": "周杰伦"}], + "al": {"name": "不能说的秘密", "id": 2}, + "dt": 280000, + }, + ] + + def album(self, album_id): + return [ + { + "id": 33894312, + "name": "邂逅", + "ar": [{"name": "周杰伦"}], + "al": {"name": "范特西", "id": 1}, + "dt": 273000, + }, + ] + def dig_info(self, data, dig_type): if dig_type == "songs": - return [ + songs = [] + for item in data or []: + sid = item.get("id") if isinstance(item, dict) else item + if sid: + songs.append( + { + "song_id": sid, + "song_name": f"song_{sid}", + "artist": f"artist_{sid}"[:10], + "album_name": f"album_{sid}"[:10], + "mp3_url": "http://example.com/song.mp3", + } + ) + return songs or [ { "song_id": 33894312, "song_name": "邂逅", "artist": "周杰伦", "album_name": "范特西", + "mp3_url": "http://example.com/song.mp3", } ] if dig_type == "artists": @@ -100,6 +143,28 @@ def get_version(self): return {"info": {"version": "9.9.9"}} +class NoUrlNetEase(FakeNetEase): + """Fake that returns songs with no mp3_url (download all fail).""" + + def dig_info(self, data, dig_type): + if dig_type == "songs": + songs = [] + for item in data or []: + sid = item.get("id") if isinstance(item, dict) else item + if sid: + songs.append( + { + "song_id": sid, + "song_name": f"song_{sid}", + "artist": "artist", + "album_name": "album", + "mp3_url": "", + } + ) + return songs + return FakeNetEase.dig_info(self, data, dig_type) + + class LoggedInNetEase(FakeNetEase): def get_account_info(self): return { @@ -460,3 +525,122 @@ def fake_main(argv): main_mod.start() assert exc.value.code == 0 assert called["argv"] == ["search", "test", "--json"] + + +def test_play_dry_run_artist_no_side_effect(monkeypatch, capsys): + fake = _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = cli.main(["play", "--artist", "6452", "--dry-run", "--json"]) + assert code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["data"][0]["method"] == "queue.clear" + assert payload["data"][1]["method"] == "queue.add" + assert payload["data"][2]["method"] == "player.play" + assert fake.calls == [] + assert fake.spawned is False + + +def test_play_dry_run_album_no_side_effect(monkeypatch, capsys): + fake = _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = cli.main(["play", "--album", "32311", "--dry-run", "--json"]) + assert code == 0 + assert fake.calls == [] + assert fake.spawned is False + + +def test_play_dry_run_songs_no_side_effect(monkeypatch, capsys): + fake = _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = cli.main( + ["play", "--songs", "33894312", "1847408145", "--dry-run", "--json"] + ) + assert code == 0 + assert fake.calls == [] + assert fake.spawned is False + + +def test_play_conflicting_flags_rejected(monkeypatch, capsys): + _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = cli.main(["play", "--id", "33894312", "--artist", "6452", "--json"]) + assert code == 2 + err = capsys.readouterr().err + assert ( + "not allowed with" in err or "mutually exclusive" in err or "conflicting" in err + ) + + +class FakeResponse: + """Mock requests.Response for download tests.""" + + def __init__(self, ok=True): + self.ok = ok + self.status_code = 200 if ok else 403 + + def raise_for_status(self): + if not self.ok: + raise Exception("HTTP Error") + + def iter_content(self, chunk_size=8192): + return iter([b"fake audio data"]) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +def test_download_artist_success(monkeypatch, capsys): + monkeypatch.setattr(cli.requests, "get", lambda url, **kw: FakeResponse(True)) + _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = _run(monkeypatch, ["download", "--artist", "6452", "--json"]) + assert code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert len(payload["data"]) == 2 + assert payload["data"][0]["ok"] is True + + +def test_download_album_success(monkeypatch, capsys): + monkeypatch.setattr(cli.requests, "get", lambda url, **kw: FakeResponse(True)) + _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = _run(monkeypatch, ["download", "--album", "32311", "--json"]) + assert code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert payload["data"][0]["ok"] is True + + +def test_download_songs_success(monkeypatch, capsys): + monkeypatch.setattr(cli.requests, "get", lambda url, **kw: FakeResponse(True)) + _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = _run(monkeypatch, ["download", "--songs", "33894312", "--json"]) + assert code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + + +def test_download_playlist_success(monkeypatch, capsys): + monkeypatch.setattr(cli.requests, "get", lambda url, **kw: FakeResponse(True)) + _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = _run(monkeypatch, ["download", "--playlist", "12345", "--json"]) + assert code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + + +def test_download_conflicting_flags_rejected(monkeypatch, capsys): + _patch_daemon(monkeypatch, FakeDaemon(running=False)) + code = cli.main(["download", "--artist", "6452", "--album", "32311", "--json"]) + assert code == 2 + err = capsys.readouterr().err + assert ( + "not allowed with" in err or "mutually exclusive" in err or "conflicting" in err + ) + + +def test_download_all_fail_exit_nonzero(monkeypatch, capsys): + monkeypatch.setattr(cli.requests, "get", lambda url, **kw: FakeResponse(True)) + code = _run(monkeypatch, ["download", "--artist", "6452", "--json"], NoUrlNetEase) + assert code != 0 + err = json.loads(capsys.readouterr().err) + assert err["ok"] is False + assert err["error"]["type"] == "download_failed"