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}` 信封。