From c9dbfd0991eb4f522f79462a5ed71353cb20e43b Mon Sep 17 00:00:00 2001 From: ming Date: Wed, 15 Jul 2026 13:48:25 +0800 Subject: [PATCH] Changes to be committed: 1. Add cli command artist show the hot songs of an artist. 2. Add cli command album, show the songs in an album. 3. Add cli command download, download songs of artist, album and playlist. 4. Enhance cli command play, support play songs of artist, album, or manual selected. --- NEMbox/cli.py | 277 +++++++++++++++++++++++++++++++++++++-- README.md | 10 ++ skills/musicbox/SKILL.md | 43 +++++- 3 files changed, 317 insertions(+), 13 deletions(-) diff --git a/NEMbox/cli.py b/NEMbox/cli.py index babf675..61855a2 100644 --- a/NEMbox/cli.py +++ b/NEMbox/cli.py @@ -5,9 +5,12 @@ import curses import io import json +import os as _os import sys from typing import Any +import requests as _requests + from . import __version__ from .api import NetEase from .config import Config @@ -611,15 +614,164 @@ 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 = [] + 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: + results.append( + { + "song_id": song_id, + "song_name": song_name, + "ok": False, + "error": "无播放链接", + } + ) + 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) + results.append( + { + "song_id": song_id, + "song_name": song_name, + "artist": artist, + "ok": True, + "path": filepath, + } + ) + except Exception as e: + results.append( + { + "song_id": song_id, + "song_name": song_name, + "ok": False, + "error": str(e), + } + ) + + ok_count = sum(1 for r in results if r["ok"]) + parts = [f"下载完成: {ok_count}/{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, + ) def cmd_simple_control(ctx: CliContext, ns: argparse.Namespace, method: str) -> int: @@ -834,6 +986,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 +1213,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 +1263,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 +1444,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 +1495,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}` 信封。