From 03c4979820b637e4386dae048549c9924966a186 Mon Sep 17 00:00:00 2001 From: ming Date: Wed, 15 Jul 2026 16:41:35 +0800 Subject: [PATCH 1/3] Add cli command `artist/album` to show songs of artist/album. Add cli command `download` to download songs of artist/album/playlist. Add `--artist/--album/--songs` to cli command play to support batch play. Update README.md and skills/musicbox/SKILL.md. modified: NEMbox/cli.py modified: README.md modified: skills/musicbox/SKILL.md --- NEMbox/cli.py | 292 +++++++++++++++++++++++++++++++++++++-- README.md | 10 ++ skills/musicbox/SKILL.md | 43 +++++- 3 files changed, 332 insertions(+), 13 deletions(-) diff --git a/NEMbox/cli.py b/NEMbox/cli.py index babf675..fbec6e9 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,179 @@ 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 + songs = api.dig_info(raw[:limit], "songs") or [] + ids = [s["song_id"] for s in songs if s.get("song_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} 的歌曲为空") + if ns.limit is not None: + raw = raw[: ns.limit] + songs = api.dig_info(raw, "songs") or [] + ids = [s["song_id"] for s in songs if s.get("song_id")] + if not ids: + return ctx.emit_err("api_error", f"无法获取专辑 {ns.album} 的歌曲信息") + elif ns.songs is not None: + ids = list(ns.songs) + if ns.limit is not None: + ids = ids[: ns.limit] + else: + return _rpc(ctx, ns, "player.play", {}, human_fn=_format_status) + + for method, params in [ + ("queue.clear", {}), + ("queue.add", {"ids": ids}), + ]: + try: + raw = send_request(method, params) + except ConnectionError as exc: + return ctx.emit_err("daemon_offline", str(exc)) + if not raw.get("ok"): + err = raw.get("error", {}) + return ctx.emit_err( + err.get("type", "rpc_error"), + err.get("message", str(raw)), + err.get("hint", ""), + ) + 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) + if limit is not None: + raw = raw[:limit] + 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 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 +1001,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 +1228,43 @@ 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 --path ./music\n" + " musicbox download --album 32311 --limit 10 --json\n" + " musicbox download --songs 33894312 28258988\n" + " musicbox download --playlist 12345" + ), + ) + _add_common_flags(p_download) + p_download.add_argument( + "--artist", type=int, default=None, help="歌手 ID,下载该歌手热门歌曲" + ) + p_download.add_argument( + "--album", type=int, default=None, help="专辑 ID,下载该专辑所有歌曲" + ) + p_download.add_argument( + "--songs", type=int, nargs="+", default=None, help="歌曲 ID 列表,下载指定歌曲" + ) + p_download.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" @@ -1049,6 +1278,19 @@ def _build_control_parsers(sub: Any) -> None: 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 首") + + p_play.add_argument( + "--artist", type=int, default=None, help="歌手 ID,播放该歌手热门歌曲" + ) + p_play.add_argument( + "--album", type=int, default=None, help="专辑 ID,播放该专辑所有歌曲" + ) + p_play.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 +1459,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 +1510,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/README.md b/README.md index 2d58c5d..60f1397 100644 --- a/README.md +++ b/README.md @@ -128,12 +128,22 @@ 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 +``` + +```bash +npx skills add darknessomi/musicbox -y ``` 安装 Agent Skill 后,可直接让 Codex、Claude Code、Cursor 等 Agent 操作 MusicBox: diff --git a/skills/musicbox/SKILL.md b/skills/musicbox/SKILL.md index b0fc308..d98b480 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 +``` + +每首歌曲下载为 ` - .mp3`,结果输出 JSON 数组,每项包含 `ok`/错误信息。 ## 输出约定 - Agent 调用时**始终加 `--json`**,解析 stdout 的 `{ok, data}` 信封。 From d964289107b24b63f477449d6adbb0714f374ad0 Mon Sep 17 00:00:00 2001 From: ming Date: Sat, 18 Jul 2026 16:29:32 +0800 Subject: [PATCH 2/3] fix 'daemon not running' when running `play --artist/--album/--songs` for the first time. --- NEMbox/cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NEMbox/cli.py b/NEMbox/cli.py index fbec6e9..158b36f 100644 --- a/NEMbox/cli.py +++ b/NEMbox/cli.py @@ -652,6 +652,10 @@ def cmd_play(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: else: return _rpc(ctx, ns, "player.play", {}, human_fn=_format_status) + failed = _ensure_daemon(ctx, ns) + if failed is not None: + return failed + for method, params in [ ("queue.clear", {}), ("queue.add", {"ids": ids}), @@ -1257,9 +1261,7 @@ def _build_control_parsers(sub: Any) -> None: p_download.add_argument( "--path", type=str, default=".", help="目标目录(默认为当前目录)" ) - p_download.add_argument( - "--limit", type=int, default=None, help="获取数量上限(仅对 --artist 生效)" - ) + p_download.add_argument("--limit", type=int, default=None, help="获取数量上限") p_download.set_defaults(handler="download") p_play = sub.add_parser( @@ -1288,9 +1290,7 @@ def _build_control_parsers(sub: Any) -> None: p_play.add_argument( "--songs", type=int, nargs="+", default=None, help="歌曲 ID 列表,播放指定歌曲" ) - p_play.add_argument( - "--limit", type=int, default=None, help="获取数量上限,仅对 --artist 生效" - ) + p_play.add_argument("--limit", type=int, default=None, help="获取数量上限") p_play.set_defaults(handler="play") # simple controls From 975915fcf9dd9b6c70965be684ec8fafdf4c002e Mon Sep 17 00:00:00 2001 From: ming Date: Mon, 20 Jul 2026 21:44:51 +0800 Subject: [PATCH 3/3] | File | Change | |------|--------| | `NEMbox/cli.py` | 6 fix items (see below) | | `skills/musicbox/SKILL.md` | Fix download filename documentation | | `tests/test_cli.py` | Add 10 new tests; extend `FakeNetEase` mock | MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- - **play**: `--id`, `--playlist`, `--index`, `--artist`, `--album`, `--songs` placed in an `add_mutually_exclusive_group()`. argparse natively rejects conflicting flags (previously the last flag silently won). - **download**: `--artist`, `--album`, `--songs`, `--playlist` placed in `add_mutually_exclusive_group(required=True)`. At least one source must be supplied. Both branches previously called `api.dig_info(raw, "songs")` just to extract `song_id`. Now they read `s["id"]` directly from the raw API payload: - `artist` raw payload (`hotSongs[]`) → `[s["id"] for s in raw[:limit] if s.get("id")]` - `album` raw payload (`songs[]`) → `[s["id"] for s in raw if s.get("id")]` This avoids URL-resolution failures inside `dig_info` that could take down the whole command. `f"歌手 {ns.artist} 没有歌曲为空"` → `f"歌手 {ns.artist} 没有歌曲"` - **play**: removed `if ns.limit is not None: raw = raw[:ns.limit]` from the `--album` and `--songs` branches. - **download**: removed `if limit is not None: raw = raw[:limit]` from the `--album` branch. - Updated `--limit` help text to `"获取数量上限(仅对 --artist 生效)"`. - Updated epilog examples to match. The old code bypassed `_rpc`, calling `send_request` directly after `_ensure_daemon`. This broke: - **dry-run**: `queue.clear` and `queue.add` ran even with `--dry-run`; only the final `player.play` was intercepted. - **error type**: `ConnectionError` was mapped to `"daemon_offline"` instead of the standard `"daemon_not_running"` used by every other control command via `_rpc`. New flow (after collecting `ids`): 1. Check `--dry-run` → emit a JSON preview of all three steps (`queue.clear`, `queue.add`, `player.play`) and return `EXIT_OK` with no side effects. 2. `_ensure_daemon` → return proper exit code if daemon cannot start. 3. For each of `queue.clear` and `queue.add`: `send_request` with `ConnectionError` mapped to `"daemon_not_running"`, and error responses mapped through `_ERROR_EXIT_CODES`. 4. Final `player.play` via `_rpc` (consistent formatting and single-step dry-run behavior for that call). When every track fails (`ok_count == 0`), the function now returns `EXIT_GENERIC` with a `"download_failed"` error type, instead of `EXIT_OK`. --- Updated download filename documentation: - **Before**: `每首歌曲下载为 - .mp3` - **After**: `每首歌曲下载为 -{ext},扩展名取决于音源(.mp3 或 .flac)` Removed hardcoded `.mp3` extension and spaces around the dash to match the actual code. --- **File**: `NEMbox/player.py` (line ~676 in `run_mpg123`) When `mpg123` exits immediately (e.g. no audio device available), `run_mpg123`'s while loop breaks immediately because `process.poll()` returns non-`None`. The post-loop decision logic then takes the `else: self.next()` path: | Flag | Value | |------|-------| | `playing_flag` | `True` (set by `replay()`) | | `copyright_issue_flag` | `False` (no `@E` frame received) | | `refresh_url_flag` | `False` | | `frame_cnt` | `0` (no `@F` frame received) | `self.next()` calls `next_idx()`, which increments `self.info["idx"]` by 1 for each failed song. Eventually `idx` reaches `len(self.list)`, making `is_index_valid` `False`. Then `replay()` sees the invalid index and stops. When `status` is queried, `current_song` returns `{}` because `is_index_valid` is `False`, and `_format_status` shows `(空队列)` — even though `queue list` correctly shows the songs. `run_mpv` does **not** have this bug because its post-loop logic calls `_advance_on_playback_failure()` when `process.returncode != 0`, which handles the failure without advancing the index. Added a `frame_cnt == 0` check before `self.next()`: ```python elif frame_cnt == 0: self.stop() self.playing_flag = False ``` `frame_cnt` is only incremented inside the `@F` branch (actual audio frames received). If `frame_cnt` is still 0 when the loop exits, the backend never started playing — so `self.next()` should not be called. Instead, the player stops cleanly with `playing_flag = False` and the index remains valid, allowing `status` to report the correct state. --- - Added `artists(artist_id)` — returns 2 songs with `id`/`name`/etc. - Added `album(album_id)` — returns 1 song. - Improved `dig_info(data, "songs")` — now iterates over all input items instead of returning a hardcoded single song. - Added `NoUrlNetEase(FakeNetEase)` — returns songs with empty `mp3_url` for all-fail tests. - Added `FakeResponse` — mock HTTP response for download tests. - Fixed 3 ruff `UP032` lint warnings (`.format()` → f-strings). | Test | What it verifies | |------|------------------| | `test_play_dry_run_artist_no_side_effect` | `play --artist 6452 --dry-run --json` → dry-run preview, no daemon calls, no queue mutation | | `test_play_dry_run_album_no_side_effect` | `play --album 32311 --dry-run --json` → same | | `test_play_dry_run_songs_no_side_effect` | `play --songs 33894312 1847408145 --dry-run --json` → same | | `test_play_conflicting_flags_rejected` | `play --id X --artist Y --json` → exit 2, argparse error on stderr | | `test_download_artist_success` | `download --artist 6452 --json` → exit 0, 2 songs downloaded | | `test_download_album_success` | `download --album 32311 --json` → exit 0 | | `test_download_songs_success` | `download --songs 33894312 --json` → exit 0 | | `test_download_playlist_success` | `download --playlist 12345 --json` → exit 0 | | `test_download_conflicting_flags_rejected` | `download --artist X --album Y --json` → exit 2 | | `test_download_all_fail_exit_nonzero` | All songs have no `mp3_url` → exit != 0, JSON `ok: false` with `"download_failed"` error type | --- ``` $ uv run pytest -q --tb=short tests/test_cli.py .......................................... => 42 passed (32 original + 10 new) $ uv run pytest -q --tb=short (full suite, excluding pre-existing daemon test) ........................................................................ ............... => all passed (except test_acquire_lock_is_exclusive, pre-existing) ``` ``` $ uv run ruff check Found 3 errors. => All 3 are pre-existing (daemon.py x2, player.py x1); no new violations. ``` Confirmed via `ast.parse()` — `cli.py` is valid Python. --- --- NEMbox/cli.py | 85 +++++++++++------- NEMbox/player.py | 3 + README.md | 4 - skills/musicbox/SKILL.md | 2 +- tests/test_cli.py | 186 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 240 insertions(+), 40 deletions(-) diff --git a/NEMbox/cli.py b/NEMbox/cli.py index 158b36f..98d839a 100644 --- a/NEMbox/cli.py +++ b/NEMbox/cli.py @@ -629,29 +629,31 @@ def cmd_play(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: if ns.artist is not None: raw = api.artists(ns.artist) if not raw: - return ctx.emit_err("api_error", f"歌手 {ns.artist} 没有歌曲为空") + return ctx.emit_err("api_error", f"歌手 {ns.artist} 没有歌曲") limit = ns.limit if ns.limit is not None else 20 - songs = api.dig_info(raw[:limit], "songs") or [] - ids = [s["song_id"] for s in songs if s.get("song_id")] + 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} 的歌曲为空") - if ns.limit is not None: - raw = raw[: ns.limit] - songs = api.dig_info(raw, "songs") or [] - ids = [s["song_id"] for s in songs if s.get("song_id")] + 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) - if ns.limit is not None: - ids = ids[: ns.limit] 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 @@ -661,15 +663,20 @@ def cmd_play(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: ("queue.add", {"ids": ids}), ]: try: - raw = send_request(method, params) - except ConnectionError as exc: - return ctx.emit_err("daemon_offline", str(exc)) - if not raw.get("ok"): - err = raw.get("error", {}) + 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", "rpc_error"), - err.get("message", str(raw)), - err.get("hint", ""), + 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) @@ -685,8 +692,6 @@ def cmd_download(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: source_id = ns.artist elif ns.album is not None: raw = api.album(ns.album) - if limit is not None: - raw = raw[:limit] songs = api.dig_info(raw, "songs") or [] source = "album" source_id = ns.album @@ -774,6 +779,13 @@ def cmd_download(api: NetEase, ctx: CliContext, ns: argparse.Namespace) -> int: 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)} 首"] @@ -1239,29 +1251,32 @@ def _build_control_parsers(sub: Any) -> None: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" - " musicbox download --artist 6452 --path ./music\n" - " musicbox download --album 32311 --limit 10 --json\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) - p_download.add_argument( + src = p_download.add_mutually_exclusive_group(required=True) + src.add_argument( "--artist", type=int, default=None, help="歌手 ID,下载该歌手热门歌曲" ) - p_download.add_argument( + src.add_argument( "--album", type=int, default=None, help="专辑 ID,下载该专辑所有歌曲" ) - p_download.add_argument( + src.add_argument( "--songs", type=int, nargs="+", default=None, help="歌曲 ID 列表,下载指定歌曲" ) - p_download.add_argument( + 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="获取数量上限") + p_download.add_argument( + "--limit", type=int, default=None, help="获取数量上限(仅对 --artist 生效)" + ) p_download.set_defaults(handler="download") p_play = sub.add_parser( @@ -1277,20 +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 首") - - p_play.add_argument( + 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,播放该歌手热门歌曲" ) - p_play.add_argument( + src.add_argument( "--album", type=int, default=None, help="专辑 ID,播放该专辑所有歌曲" ) - p_play.add_argument( + src.add_argument( "--songs", type=int, nargs="+", default=None, help="歌曲 ID 列表,播放指定歌曲" ) - p_play.add_argument("--limit", type=int, default=None, help="获取数量上限") + p_play.add_argument( + "--limit", type=int, default=None, help="获取数量上限(仅对 --artist 生效)" + ) p_play.set_defaults(handler="play") # simple controls 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 60f1397..09a40ab 100644 --- a/README.md +++ b/README.md @@ -142,10 +142,6 @@ musicbox auth login --no-wait --json musicbox download --playlist 3778678 --path ./music --json ``` -```bash -npx skills add darknessomi/musicbox -y -``` - 安装 Agent Skill 后,可直接让 Codex、Claude Code、Cursor 等 Agent 操作 MusicBox: ```bash diff --git a/skills/musicbox/SKILL.md b/skills/musicbox/SKILL.md index d98b480..9aafa0e 100644 --- a/skills/musicbox/SKILL.md +++ b/skills/musicbox/SKILL.md @@ -111,7 +111,7 @@ musicbox download --album --path ./music --json musicbox download --songs --path ./music --json ``` -每首歌曲下载为 ` - .mp3`,结果输出 JSON 数组,每项包含 `ok`/错误信息。 +每首歌曲下载为 `-{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"