1158 lines
60 KiB
Bash
Executable File
1158 lines
60 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# netease-music — 直接调接口放网易云音乐(不再模拟鼠标点界面)
|
||
#
|
||
# 架构(三层,各层可单独验证):
|
||
# 取数 scripts/netease-api.mjs 直连 music.163.com 的 weapi(自包含加密,零 npm 依赖)
|
||
# 播放 scripts/mpv-ctl.mjs 常驻 mpv + unix socket JSON IPC(状态是真值,不靠截屏猜)
|
||
# 入口 本脚本 把「用户意图」翻译成上面两层的调用
|
||
#
|
||
# 实测结论(勿改):
|
||
# * 搜索必须走明文接口 /api/search/get/web:weapi 版 cloudsearch 对匿名请求回空体(HTTP 200 空)。
|
||
# * weapi 的 aesEncrypt 输出是 base64,不是 hex(用 hex 会得到 HTTP 200 空响应)。
|
||
# * mpv 的 `cycle pause` 会报 invalid parameter,改 set_property。
|
||
# * mpv 的 shuffle 会重排它自己的播放顺序:playlist-pos 不能直接索引 queue.json(实测会报错歌,
|
||
# status 说在放《野心家》、实际在放《All Of Me》);映射一律走 scripts/lib/mpv-map.py(标题 [#id] → URL → 名字)。
|
||
# * 登录态 = MUSIC_U cookie,来自扫码登录的 Set-Cookie;存 $NETEASE_MUSIC_STATE_DIR/config.json。
|
||
set -uo pipefail
|
||
|
||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
|
||
# 网易云是国内服务:显式绕开 shell 里的代理变量。
|
||
# 否则 sing-box 一关(7890 端口没了),接口和 mpv/ffmpeg 取音频流会一起挂。
|
||
# node 本来就不认这些变量,但 mpv/ffmpeg/curl 认,所以必须显式清掉。
|
||
unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy 2>/dev/null || true
|
||
export NO_PROXY="music.163.com,.163.com,.126.net,.127.net,.heiyu.space,localhost,127.0.0.1${NO_PROXY:+,$NO_PROXY}"
|
||
export no_proxy="$NO_PROXY"
|
||
# 状态目录(cookie/队列/缓存):默认跟在技能目录旁;standalone 用户用 NETEASE_MUSIC_STATE_DIR 指走。
|
||
LOCAL_DIR="${NETEASE_MUSIC_STATE_DIR:-${PI_AGENT_DIR:-$HOME/.pi/agent}/local/skills/netease-music}"
|
||
CFG="$LOCAL_DIR/config.json"
|
||
API="$SKILL_DIR/scripts/netease-api.mjs"
|
||
MPVCTL="$SKILL_DIR/scripts/mpv-ctl.mjs"
|
||
QUEUE="$LOCAL_DIR/queue.json"
|
||
M3U="$LOCAL_DIR/queue.m3u8"
|
||
|
||
JSON=0; QUIET=0
|
||
RESULT_KEYS=(); RESULT_VALS=()
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
netease-music — 网易云音乐:直接调接口播放与控制(不点界面)
|
||
|
||
用法:netease-music <命令> [参数] [--json] [--quiet]
|
||
|
||
播放:
|
||
daily [--limit N] [--shuffle] 每日推荐,整列入队开播
|
||
play <关键词|歌曲id|歌单id|歌单链接> [--shuffle] [--limit N]
|
||
关键词取最匹配的一首;给歌单/歌手/专辑链接都能放
|
||
play-ids <id,id,...> [--start N] 按输入顺序替换队列,N 从 1 开始(默认 1;单个逗号分隔参数)
|
||
search <关键词> [--limit N] 只看不播:列出搜索结果
|
||
cover <歌曲关键词|id> [--download PATH] [--force]
|
||
封面 JSON;可下载至 PATH(最多 10 MB,覆盖需 --force)
|
||
liked [--shuffle] 我喜欢的音乐(整列入队)
|
||
liked-ids [--json] 只读喜欢 ID 全表;JSON 为 {count,total,ids:[数字,...]}
|
||
artist <歌手|id> [--limit N] 歌手热门整列入队(默认 50 首)
|
||
album <专辑|id> [--limit N] 专辑整张按顺序入队(搜不到正版先用 albums 拿 id)
|
||
myplaylists [collected|own] [--limit N] [--offset N]
|
||
我的歌单(含「我喜欢的音乐」id;collected 只看收藏的)
|
||
albums <歌手|id> [--limit N] 只看不播:列出歌手的专辑(含搜索里搜不到的老专辑)
|
||
歌单与榜单:
|
||
playlist <歌单名|id|链接> [--shuffle] 放一张歌单(别人的也行,名字直接写)
|
||
playlists <关键词> [--limit N] 只看不播:搜歌单(含作者、是否已收藏)
|
||
charts [--limit N] 只看不播:列排行榜(飙升榜/新歌榜/…)
|
||
chart <榜名|id> [--shuffle] 放某个榜单(例:chart 飙升榜)
|
||
myalbums / myartists [--limit N] 只看不播:我收藏的专辑 / 歌手
|
||
fm [--limit N] [--shuffle] 私人 FM(没听过的)
|
||
dislike [歌曲id] 私人 FM 不喜欢;省略 id 用当前歌曲,只上报,不自动切歌
|
||
recent [--limit N] 只看不播:最近播放
|
||
replay [N] 再放一次最近第 N 首(默认 1)
|
||
podcasts [--recommend] [--limit N] 只看不播:我的播客订阅(--recommend 看推荐的)
|
||
podcast <播客名|id> [--limit N] 放某播客最新 N 期(默认 3)
|
||
episode <节目id> 放某一期(id 从 podcast 列表里抄)
|
||
队列现场:
|
||
queue [--limit N] 看队列:每首歌名、时长,标出正在放的那首
|
||
jump <N> 跳到队列第 N 首(从 1 开始)
|
||
add <歌曲|id> 把一首加到队尾(不动正在放的那首)
|
||
remove <N> 删除 queue 显示的第 N 首;当前项选下一首,末尾选上一首,删空停止
|
||
clear 停止并清空队列及本地队列文件(保留快照)
|
||
queues 看本地的队列快照(每次换队列会自动留一条「上一条」)
|
||
queue-save [名字] 把当前队列存成命名快照
|
||
queue-load <名字> 载入某个快照(当前这条会留成「上一条」)
|
||
back 切回上一条队列(含当时放到哪儿)
|
||
控制:
|
||
pause / resume / toggle 暂停 / 继续 / 切换
|
||
next / prev / stop 下一首 / 上一首 / 停止
|
||
volume [0-130] 不填=读当前音量,填了=设置
|
||
seek <秒|+n|-n> 跳进度(相对)
|
||
status 当前播放状态(mpv 真值 + 曲目信息)
|
||
lyric [--all] 当前这首歌的歌词(默认只显示当前句与下一句)
|
||
like / unlike [--unlike] 喜欢 / 取消喜欢当前这首歌
|
||
shuffle on|off 随机开关
|
||
repeat off|list|single 关闭 / 整列循环 / 单曲循环(on = list);status 带 repeat_mode
|
||
配置:
|
||
setup [--cookie 值] [--quality 级别] 写配置(不带参数=告诉你登录的两种办法)
|
||
login [--timeout 300] [--refresh 4] 扫码登录(码 5 分钟过期,自动换新;cookie 只存本机)
|
||
login --sms <手机号> [--sms-code <验证码>] 备用:短信验证码登录(两步)
|
||
check 自检:依赖 / 登录态 / mpv / 音质
|
||
help [命令]
|
||
|
||
输出:首行是人读结论,随后 key=value;读取类命令都带 source=api 与 as_of=<ISO 时刻>(说清数据来自哪个接口、什么时候取的)。
|
||
cover 始终只输出一个 JSON 对象;liked-ids --json 输出完整数字 ID 数组(均不改变队列或登录态)。
|
||
旗标:--json 输出 JSON;--quiet 不打印人读结论行
|
||
分页:列表用 --limit N --offset N;offset 默认 0,跳过 N 条;后端 JSON 的 count 是本页条数,total 是总数。
|
||
适用 daily / playlist / liked / album / artist / albums / chart / search / playlists / myplaylists / charts / myalbums / myartists / recent / podcasts / podcast。
|
||
环境变量(或 local/skills/netease-music/config.json):
|
||
NETEASE_MUSIC_STATE_DIR 状态目录(cookie/队列/缓存,默认 ~/.local/state/netease-music)
|
||
NETEASE_MUSIC_COOKIE 登录 cookie(MUSIC_U=…)
|
||
NETEASE_MUSIC_QUALITY 音质:standard/higher/exhigh/lossless/hires(默认 lossless)
|
||
退出码:0 成功;2 用法错;3 缺依赖或未登录;4 找不到歌曲/歌单;5 执行失败
|
||
EOF
|
||
}
|
||
|
||
cmd_help() {
|
||
case "${1:-}" in
|
||
daily) echo "daily — 拉今日「每日推荐」整列入队播放;--limit N 只取前 N 首,--shuffle 随机。" ;;
|
||
play) echo "play <关键词|id|歌单链接> — 关键词→搜最匹配一首;纯数字→歌曲 id;歌单链接或 playlist:<id>→整张列表。会跳过无版权/VIP 不可播的。" ;;
|
||
play-ids) echo "play-ids <id,id,...> [--start N] — id 列表是单个无空格逗号分隔参数,顺序与重复项保留;N 为输入列表的 1 基序号,默认 1。不支持 --shuffle;指定歌曲不可播/详情缺失时报错,旧队列不变;其他无地址项跳过。例:play-ids 1000,1001,1002 --start 2。" ;;
|
||
search) echo "search <关键词> [--limit N] — 只列出结果(id / 歌名 / 歌手 / 时长 / 是否需 VIP),不播放。" ;;
|
||
cover) echo "cover <歌曲关键词|id> [--download PATH] [--force] — 输出 {id,name,artist,cover};下载输出 {id,cover,file,bytes},最多 10 MB(10000000 字节),覆盖需 --force;不改变队列或登录态。" ;;
|
||
liked) echo "liked — 把「我喜欢的音乐」整列入队播放。" ;;
|
||
liked-ids) echo "liked-ids [--json] — 只读喜欢 ID 全表;--json 为 {count,total,ids:[数字,...]},不分页。歌曲 JSON 有明确布尔 liked 才透传,缺字段时用这张全表按 id 判定。" ;;
|
||
myplaylists) echo "myplaylists [collected|own] [--limit N] [--offset N] — 分页列出我的歌单;collected/own 在本页内筛选。" ;;
|
||
artist) echo "artist <歌手|id> [--limit N] — 歌手热门整列入队(默认 50 首;也可直接给数字 id,如 6452=周杰伦)。" ;;
|
||
album) echo "album <专辑|id> [--limit N] — 专辑整张按顺序入队(默认 200 首)。搜不到正版专辑时先跑 albums <歌手> 拿 id。" ;;
|
||
albums) echo "albums <歌手|id> [--limit N] — 只列出这位歌手的专辑(id / 专辑名 / 年份 / 曲目数),不播放。" ;;
|
||
queue) echo "queue [--limit N] — 打印队列(mpv 真值):每首序号、歌名、时长,正在放的那首带 ← 正在放。" ;;
|
||
jump) echo "jump <N> — 跳到队列第 N 首(从 1 开始),并回读状态确认。" ;;
|
||
remove) echo "remove <N> — 按 queue 显示的 1 基序号删除;删除当前项选下一首,末尾选上一首,删空则停止;保留暂停状态,同步本地队列文件。" ;;
|
||
clear) echo "clear — 停止并清空 mpv 和本地队列文件,收掉保活;配置和快照保留,可重复调用。" ;;
|
||
pause|resume|toggle) echo "$1 — 控制播放状态(mpv 真值)。" ;;
|
||
next|prev) echo "$1 — 队列上下首。" ;;
|
||
volume) echo "volume [0-130] — 不带参数读当前音量,带参数设置。" ;;
|
||
lyric) echo "lyric [--all] — 打印当前歌曲歌词;默认只显示当前句与下一句(按播放进度对齐)。" ;;
|
||
like) echo "like / unlike — 把当前播放的这首加入/移出「我喜欢的音乐」。" ;;
|
||
dislike) echo "dislike [歌曲id] — 提交私人 FM 的 fm/trash;省略 id 用当前歌曲,显式 id 必须是正整数。只上报不喜欢,不自动跳歌或改队列;成功以接口 code=200 为准。" ;;
|
||
repeat) echo "repeat off|list|single — 关闭 / 整列循环 / 单曲循环;on 和无参数沿用 list。status 的 repeat_mode 报 off/list/single,原 repeat 布尔字段仍表示整列循环。" ;;
|
||
setup) echo "setup — 把 cookie/音质写进 config.json;不带参数只打印怎么登录。" ;;
|
||
login) echo "login [--timeout 300] [--refresh 4] — 生成登录二维码(终端 + 图片),手机网易云 App 扫码;成功后把 cookie 存进 config.json。" ;;
|
||
status) echo "status — 当前在放什么/进度/音量/队列位置,全部来自 mpv;repeat_mode 为 off/list/single。" ;;
|
||
check) echo "check — 只读自检:依赖、登录态、mpv 是否可用、默认音质。" ;;
|
||
playlist) echo "playlist <歌单名|id|链接> — 放一张歌单,别人的也行(名字直接写:playlist 夜跑)。" ;;
|
||
playlists) echo "playlists <关键词> [--limit N] — 只列出搜到的歌单(id / 名字 / 曲目数 / 作者 / 是否已收藏)。" ;;
|
||
charts) echo "charts [--limit N] — 只列出排行榜(id / 名字 / 曲目数 / 更新频率)。" ;;
|
||
chart) echo "chart <榜名|id> [--limit N] — 放某个榜单整列(例:chart 飙升榜)。" ;;
|
||
myalbums|myartists) echo "$1 [--limit N] — 只列出我收藏的专辑 / 歌手(id 可直接喂 album/artist)。" ;;
|
||
fm) echo "fm [--limit N] — 私人 FM(没听过的),整列入队开播。" ;;
|
||
recent) echo "recent [--limit N] — 只列出最近播放(一周,含播放次数)。" ;;
|
||
replay) echo "replay [N] — 再放一次最近第 N 首(默认 1 = 最近那首)。" ;;
|
||
podcasts) echo "podcasts [--recommend] [--limit N] — 只列出我的播客订阅(或推荐的播客)。" ;;
|
||
podcast) echo "podcast <播客名|id> [--limit N] — 放某播客最新 N 期(默认 3)。" ;;
|
||
episode) echo "episode <节目id> — 放某一期节目(id 从 podcast 输出里抄)。" ;;
|
||
queues) echo "queues — 看本地的队列快照:文件、首数、存的时间、来源。" ;;
|
||
queue-save) echo "queue-save [名字] — 把当前队列存成命名快照(快照只存歌单列表,地址回放时重取)。" ;;
|
||
queue-load) echo "queue-load <名字> — 载入某个快照,回到它当时的位置;当前这条会留成「上一条」。" ;;
|
||
back) echo "back — 切回上一条队列(含当时的位置与随机状态);再 back 一次就切回来。" ;;
|
||
add) echo "add <歌曲|id> — 把一首加到当前队列尾部,不动正在放的那首。" ;;
|
||
*) usage ;;
|
||
esac
|
||
case "${1:-}" in
|
||
daily|playlist|liked|album|artist|albums|chart|search|playlists|myplaylists|charts|myalbums|myartists|recent|podcasts|podcast)
|
||
echo "分页:--offset N 从 0 起跳过 N 条,--limit N 最多取 N 条;JSON 的 total 优先用接口总数,缺失时用接口返回的条数。" ;;
|
||
esac
|
||
}
|
||
|
||
die() { printf 'error=%s\nhint=%s\n' "$1" "$2" >&2; exit "${3:-1}"; }
|
||
kv() { RESULT_KEYS+=("$1"); RESULT_VALS+=("$2"); }
|
||
kv_api_total() { # total 只补进 JSON,保留原有人读结果块
|
||
[ "$JSON" = 1 ] || return 0
|
||
kv total "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["total"])' <<<"$1")"
|
||
}
|
||
flush_result() {
|
||
local head="$1" i
|
||
if [ "$JSON" = 1 ]; then
|
||
local argv=("$head")
|
||
[ "${#RESULT_KEYS[@]}" -gt 0 ] && argv+=("${RESULT_KEYS[@]}")
|
||
argv+=(--sep--)
|
||
[ "${#RESULT_VALS[@]}" -gt 0 ] && argv+=("${RESULT_VALS[@]}")
|
||
python3 - "${argv[@]}" <<'PY'
|
||
import json, sys
|
||
a = sys.argv[1:]
|
||
i = a.index('--sep--')
|
||
head, keys, vals = a[0], a[1:i], a[i + 1:]
|
||
print(json.dumps({"ok": True, "conclusion": head, **dict(zip(keys, vals))}, ensure_ascii=False))
|
||
PY
|
||
else
|
||
[ "$QUIET" = 1 ] || printf '%s\n' "$head"
|
||
[ "${#RESULT_KEYS[@]}" -gt 0 ] || return 0
|
||
for i in "${!RESULT_KEYS[@]}"; do printf '%s=%s\n' "${RESULT_KEYS[$i]}" "${RESULT_VALS[$i]}"; done
|
||
fi
|
||
}
|
||
now() { date +%Y-%m-%dT%H:%M:%S%z; }
|
||
mmss() { local s="${1:-0}"; printf '%d:%02d' $((s / 60)) $((s % 60)); }
|
||
|
||
env_get() { local v; v="$(env | sed -n "s/^$1=//p" | head -1)"; printf '%s' "$v"; }
|
||
conf_get() {
|
||
[ -f "$CFG" ] || return 0
|
||
python3 -c 'import json,sys
|
||
try: d=json.load(open(sys.argv[1]))
|
||
except Exception: sys.exit(0)
|
||
v=d.get(sys.argv[2])
|
||
print(v if isinstance(v,str) else ("" if v is None else v))' "$CFG" "$1" 2>/dev/null
|
||
}
|
||
conf_set() { # conf_set <键> <值>
|
||
mkdir -p "$LOCAL_DIR"
|
||
python3 - "$CFG" "$1" "$2" <<'PY'
|
||
import json, os, sys
|
||
p, k, v = sys.argv[1], sys.argv[2], sys.argv[3]
|
||
d = {}
|
||
if os.path.exists(p):
|
||
try: d = json.load(open(p))
|
||
except Exception: d = {}
|
||
d[k] = v
|
||
json.dump(d, open(p, 'w'), indent=2, ensure_ascii=False)
|
||
PY
|
||
}
|
||
|
||
need_deps() {
|
||
command -v node >/dev/null 2>&1 || die deps "缺 node" 3
|
||
command -v python3 >/dev/null 2>&1 || die deps "缺 python3" 3
|
||
[ -f "$API" ] || die deps "缺脚本 $API" 3
|
||
}
|
||
|
||
api() { # api <命令> [参数];stdout=JSON,stderr=错误 JSON
|
||
NETEASE_COOKIE="$COOKIE" node "$API" "$@" --offset "$OFFSET"
|
||
}
|
||
api_or_die() {
|
||
local out err rc
|
||
err="$(mktemp)"; out="$(api "$@" 2>"$err")"; rc=$?
|
||
if [ $rc -ne 0 ]; then
|
||
local code hint
|
||
code="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("error","external"))' "$err" 2>/dev/null || echo external)"
|
||
hint="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hint",""))' "$err" 2>/dev/null || echo "接口调用失败")"
|
||
rm -f "$err"
|
||
die "$code" "$hint" "$rc"
|
||
fi
|
||
rm -f "$err"
|
||
printf '%s' "$out"
|
||
}
|
||
api_out() { # api_out <参数...>:与 api_or_die 同,但用在 $( ) 里也能让整个脚本停下来
|
||
local out rc
|
||
out="$(api_or_die "$@")"; rc=$?
|
||
[ "$rc" -eq 0 ] || exit "$rc"
|
||
printf '%s' "$out"
|
||
}
|
||
mpvctl() { node "$MPVCTL" "$@"; }
|
||
mpvctl_or_die() {
|
||
local out rc
|
||
out="$(mpvctl "$@")"; rc=$?
|
||
[ $rc -eq 0 ] || exit $rc
|
||
printf '%s' "$out"
|
||
}
|
||
|
||
# ---------- 队列:把歌曲列表变成 mpv 能放的 m3u8 + 记录 id 的 queue.json ----------
|
||
build_queue() { # stdin = 接口返回的歌曲 JSON 数组
|
||
local raw; raw="$(cat)"
|
||
mkdir -p "$LOCAL_DIR"
|
||
local stage rc
|
||
stage="$(mktemp -d "$LOCAL_DIR/.queue-build.XXXXXX")" || return 5
|
||
printf '%s' "$raw" > "$stage/songs.json"
|
||
: > "$stage/urls.txt"
|
||
local chunk=() id
|
||
for id in $(python3 -c 'import json,sys
|
||
print(" ".join(str(s["id"]) for s in json.load(open(sys.argv[1]))))' "$stage/songs.json"); do
|
||
chunk+=("$id")
|
||
if [ ${#chunk[@]} -ge 50 ]; then
|
||
api_or_die url "${chunk[@]}" --level "$QUALITY" | pick_urls >> "$stage/urls.txt"
|
||
rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; return "$rc"; }
|
||
chunk=()
|
||
fi
|
||
done
|
||
if [ ${#chunk[@]} -gt 0 ]; then
|
||
api_or_die url "${chunk[@]}" --level "$QUALITY" | pick_urls >> "$stage/urls.txt"
|
||
rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; return "$rc"; }
|
||
fi
|
||
python3 "$SKILL_DIR/scripts/lib/build-queue.py" "$stage/songs.json" "$stage/urls.txt" "$stage/queue.json" "$stage/queue.m3u8" "$@"
|
||
rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; return "$rc"; }
|
||
[ "${SKIP_SNAPSHOT:-0}" = 1 ] || snap_auto
|
||
keepalive_stop
|
||
local file
|
||
for file in songs.json urls.txt queue.json queue.m3u8; do
|
||
mv "$stage/$file" "$LOCAL_DIR/$file" || { rm -rf "$stage"; return 5; }
|
||
done
|
||
rmdir "$stage"
|
||
date +%s > "$LOCAL_DIR/urls_at" # 这批地址的取用时刻:网易云直链是限时签名,约 5 分钟就 403
|
||
}
|
||
|
||
pick_urls() { # 从 url 接口的 JSON 里挑出 <id>\t<服务端给的档位>\t<url>,丢掉拿不到地址的
|
||
python3 -c 'import json,sys
|
||
for it in json.load(sys.stdin)["items"]:
|
||
if it.get("url"): print("%s\t%s\t%s" % (it["id"], it.get("level") or "", it["url"]))'
|
||
}
|
||
|
||
# ---------- mpv 顺序 ↔ queue.json:开 shuffle 后两者不同,只能按「歌」认,不能按位置 ----------
|
||
# 每首歌的稳定身份写在 m3u 标题里([#id]),旧队列/地址刷新时回退按 URL、标题串认(见 lib/mpv-map.py)。
|
||
mpv_map() { python3 "$SKILL_DIR/scripts/lib/mpv-map.py" "$@"; }
|
||
|
||
song_by_id() { # 从 queue.json 按 id 取歌曲 JSON(单行);取不到给空
|
||
python3 - "$QUEUE" "${1:-}" <<'PY'
|
||
import json, sys
|
||
try: songs = json.load(open(sys.argv[1]))["songs"]
|
||
except Exception: sys.exit(0)
|
||
for s in songs:
|
||
if str(s["id"]) == sys.argv[2]:
|
||
print(json.dumps(s, ensure_ascii=False)); break
|
||
PY
|
||
}
|
||
|
||
cur_song_json() { # 当前 mpv 真正在放的那首(JSON);没有/对不上给空
|
||
local st; st="$(mpvctl status 2>/dev/null)" || return 0
|
||
printf '%s' "$st" > "${TMPDIR:-/tmp}/nm_cur.$$"
|
||
mpv_map now "$QUEUE" "$LOCAL_DIR/urls.txt" "${TMPDIR:-/tmp}/nm_cur.$$" 2>/dev/null
|
||
rm -f "${TMPDIR:-/tmp}/nm_cur.$$"
|
||
}
|
||
|
||
cur_id() { cur_song_json | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null; }
|
||
|
||
jump_index_of_id() { # <歌曲id> → 该歌在 mpv 当前队列里的序号(1 起);找不到给空
|
||
local idx; mpvctl_or_die playlist > "${TMPDIR:-/tmp}/nm_pl.$$"
|
||
idx="$(mpv_map find "$QUEUE" "$LOCAL_DIR/urls.txt" "$1" "${TMPDIR:-/tmp}/nm_pl.$$" 2>/dev/null)" || idx=""
|
||
rm -f "${TMPDIR:-/tmp}/nm_pl.$$"
|
||
printf '%s' "$idx"
|
||
}
|
||
|
||
jump_target() { # <队列第 N 首> → mpv 队列序号(随机播放时按歌定位,别跳错歌)
|
||
local n="$1" id idx shuf
|
||
case "$n" in ''|*[!0-9]*) die usage "jump 需要队列序号(例:netease-music jump 3)" 2 ;; esac
|
||
id="$(python3 - "$QUEUE" "$n" <<'PY'
|
||
import json, sys
|
||
try: songs = json.load(open(sys.argv[1]))["songs"]
|
||
except Exception: sys.exit(0)
|
||
i = int(sys.argv[2]) - 1
|
||
print(songs[i]["id"] if 0 <= i < len(songs) else "")
|
||
PY
|
||
)"
|
||
[ -n "$id" ] || die notfound "队列里没有第 $n 首(先 queue 看队列)" 4
|
||
idx="$(jump_index_of_id "$id")"
|
||
[ -n "$idx" ] && { printf '%s' "$idx"; return 0; }
|
||
# 兜底:队列没打乱时序号就是序号;打乱了又定位不到就明确报出来,别静默跳错歌
|
||
shuf="$(mpvctl_or_die status | python3 -c 'import json,sys;print(str(json.load(sys.stdin).get("shuffle",False)).lower())')"
|
||
if [ "$shuf" != true ]; then printf '%s' "$n"; else
|
||
die notfound "这首歌在当前 mpv 队列里定位不到(标题是旧格式或地址已刷新),随机播放时不能可靠跳转;先 shuffle off 或重放一次队列" 4
|
||
fi
|
||
}
|
||
|
||
play_queue() { # play_queue [--index N]
|
||
local total; total="$(python3 -c 'import json,sys;print(len(json.load(open(sys.argv[1]))["songs"]))' "$QUEUE" 2>/dev/null || echo 0)"
|
||
[ "${total:-0}" -gt 0 ] || die notfound "没有一首能播(可能都要 VIP 或没版权):换一首/换张专辑,或先 search 看看" 4
|
||
local sf n
|
||
sf="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["skipped"])' "$QUEUE")"
|
||
n="$(python3 -c 'import json,sys;print(len(json.load(open(sys.argv[1]))["songs"]))' "$QUEUE")"
|
||
kv queue "$n"
|
||
[ "$sf" != "0" ] && kv skipped "${sf}(要 VIP 或没版权)"
|
||
mpvctl_or_die play "$M3U" "$@" >/dev/null
|
||
# mpv 开了 shuffle 时「正在放」不一定是第一首:等它真的开出声再回读(最多 1.5 秒)
|
||
local cur="" i=0
|
||
while [ "$i" -lt 10 ]; do
|
||
cur="$(cur_song_json)"; [ -n "$cur" ] && break
|
||
sleep 0.15; i=$((i + 1))
|
||
done
|
||
[ -n "$cur" ] || cur="$(song_by_id "$(python3 -c 'import json,sys;s=json.load(open(sys.argv[1]))["songs"];print(s[0]["id"])' "$QUEUE")")"
|
||
local info now_name now_artist now_id now_level
|
||
info="$(python3 -c 'import json,sys
|
||
s=json.load(sys.stdin)
|
||
print("%s\t%s\t%s\t%s" % (s["name"], s["artist"], s["id"], s.get("level") or ""))' <<<"$cur")"
|
||
IFS=$'\t' read -r now_name now_artist now_id now_level <<<"$info"
|
||
kv now_playing "$now_name - $now_artist"
|
||
kv song_id "$now_id"
|
||
kv quality "${now_level:-$QUALITY}"
|
||
[ -n "$now_level" ] && [ "$now_level" != "$QUALITY" ] && kv quality_requested "$QUALITY"
|
||
kv source mpv; kv as_of "$(now)"
|
||
flush_result "开始播放:$now_name - ${now_artist}(队列 $n 首)"
|
||
keepalive_start
|
||
}
|
||
|
||
# ---------- 命令 ----------
|
||
cmd_daily() {
|
||
local songs; songs="$(api_out daily --limit "$LIMIT")" || exit $?
|
||
kv_api_total "$songs"
|
||
python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $?
|
||
apply_shuffle
|
||
play_queue
|
||
}
|
||
cmd_liked() {
|
||
local songs; songs="$(api_out liked --limit "${LIMIT:-1000}")" || exit $?
|
||
kv_api_total "$songs"
|
||
python3 -c 'import json,sys;d=json.load(sys.stdin);print(json.dumps(d["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $?
|
||
apply_shuffle
|
||
play_queue
|
||
}
|
||
cmd_playlist() { # cmd_playlist <id|链接>
|
||
local id="$1" songs
|
||
songs="$(api_out playlist "$id" --limit "${LIMIT:-500}")" || exit $?
|
||
kv_api_total "$songs"
|
||
python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $?
|
||
local name; name="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])' <<<"$songs")"
|
||
apply_shuffle
|
||
play_queue
|
||
}
|
||
cmd_artist() { # cmd_artist <关键词|id>
|
||
local q="$1" songs
|
||
[ -n "$q" ] || die usage "artist 需要歌手名或 id(例:netease-music artist 陈粒)" 2
|
||
songs="$(api_out artist "$q" --limit "${LIMIT:-50}")" || exit $?
|
||
kv_api_total "$songs"
|
||
python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $?
|
||
apply_shuffle
|
||
play_queue
|
||
}
|
||
cmd_album() { # cmd_album <关键词|id>
|
||
local q="$1" songs
|
||
[ -n "$q" ] || die usage "album 需要专辑名或 id(找 id:netease-music albums <歌手>)" 2
|
||
songs="$(api_out album "$q" --limit "${LIMIT:-200}")" || exit $?
|
||
kv_api_total "$songs"
|
||
python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$songs" | build_queue >/dev/null || exit $?
|
||
apply_shuffle
|
||
play_queue
|
||
}
|
||
cmd_albums() { # 歌手的专辑列表(只读)
|
||
local q="$1" j n name
|
||
[ -n "$q" ] || die usage "albums 需要歌手名或 id(例:netease-music albums 周杰伦)" 2
|
||
j="$(api_out albums "$q" --limit "${LIMIT:-60}")" || exit $?
|
||
kv_api_total "$j"
|
||
name="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])' <<<"$j")"
|
||
n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
kv artist "$name"; kv count "$n"; kv source api; kv as_of "$(now)"
|
||
flush_result "找到 $n 张专辑:$name"
|
||
[ "$JSON" = 1 ] || python3 -c 'import json, sys
|
||
for a in json.load(sys.stdin)["albums"]:
|
||
print(" %-10s %s (%s, %d 首)" % (a["id"], a["name"], a["year"] or "—", a["size"]))' <<<"$j"
|
||
}
|
||
cmd_queue() { # 看队列(真值来自 mpv 的 playlist;时长对齐 queue.json)
|
||
local st; st="$(mpvctl_or_die playlist)"
|
||
python3 - "$st" "${LIMIT:-0}" "$QUEUE" "${TMPDIR:-/tmp}/nm_queue.$$" "${TMPDIR:-/tmp}/nm_queue_list.$$" "$LOCAL_DIR/urls.txt" <<'INNER_PY_END'
|
||
import json, re, sys
|
||
st = json.loads(sys.argv[1])
|
||
limit = int(sys.argv[2]) if sys.argv[2] else 0
|
||
qpath, kvout, listout, urls_path = sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6]
|
||
count = st.get("count", 0); pos = st.get("pos", -1); entries = st.get("entries", [])
|
||
q = {}
|
||
try: q = json.load(open(qpath))
|
||
except Exception: pass
|
||
songs = q.get("songs") or []
|
||
by_id = {str(s["id"]): s for s in songs}
|
||
def strip_query(u): return (u or "").split("?")[0]
|
||
by_url = {} # 旧格式标题/地址刷新前:条目 filename → 歌 id
|
||
try:
|
||
for line in open(urls_path, encoding="utf-8"):
|
||
parts = line.rstrip("\n").split("\t")
|
||
if len(parts) >= 2 and parts[-1]:
|
||
by_url[strip_query(parts[-1])] = parts[0]
|
||
except OSError:
|
||
pass
|
||
if limit: entries = entries[:limit]
|
||
def entry_song(e): # 身份:标题 [#id] → URL → 标题串「歌手 - 歌名」;都不行就不配时长(宁可缺,不可错)
|
||
t = e.get("title") or ""
|
||
m = re.match(r"^\[#(\d+)\]\s*", t)
|
||
sid = m.group(1) if m else by_url.get(strip_query(e.get("filename") or ""))
|
||
if sid and sid in by_id:
|
||
return by_id[sid], re.sub(r"^\[#\d+\]\s*", "", t)
|
||
for s in songs:
|
||
if t.strip() == "%s - %s" % (s.get("artist") or "", s.get("name") or ""):
|
||
return s, t
|
||
return None, t
|
||
with open(kvout, "w") as kf, open(listout, "w") as lf:
|
||
kf.write("HEAD\t" + ("队列 %d 首(当前第 %d 首)" % (count, pos + 1) if count else "队列是空的(先 play / daily / liked / artist / album)") + "\n")
|
||
for e in entries:
|
||
i = e["index"] - 1
|
||
s, title = entry_song(e)
|
||
ms = (s.get("ms") or 0) if s else (int(e["duration"]) * 1000 if e.get("duration") else 0)
|
||
dur = (" %d:%02d" % (ms // 60000, ms // 1000 % 60)) if ms else ""
|
||
lf.write("%4d. %s%s%s\n" % (e["index"], title or "(未知)", dur, " ← 正在放" if i == pos else ""))
|
||
kf.write("count\t%d\n" % count)
|
||
if pos >= 0: kf.write("pos\t%d\n" % (pos + 1))
|
||
kf.write("source\tmpv\n")
|
||
INNER_PY_END
|
||
local head; head="$(head -1 "${TMPDIR:-/tmp}/nm_queue.$$" | cut -f2-)"
|
||
while IFS=$'\t' read -r k v; do [ "$k" = HEAD ] || kv "$k" "$v"; done < "${TMPDIR:-/tmp}/nm_queue.$$"
|
||
kv as_of "$(now)"
|
||
flush_result "$head"
|
||
[ "$JSON" = 1 ] || cat "${TMPDIR:-/tmp}/nm_queue_list.$$"
|
||
rm -f "${TMPDIR:-/tmp}/nm_queue.$$" "${TMPDIR:-/tmp}/nm_queue_list.$$"
|
||
}
|
||
|
||
cmd_play() { # cmd_play <关键词|id|链接>
|
||
local arg="$1"
|
||
case "$arg" in
|
||
*music.163.com*artist*|artist:*) cmd_artist "${arg#artist:}"; return ;;
|
||
*music.163.com*album*|album:*) cmd_album "${arg#album:}"; return ;;
|
||
*music.163.com*playlist*|*music.163.com*toplist*|playlist:*|*list=*)
|
||
cmd_playlist "${arg#playlist:}"; return ;;
|
||
esac
|
||
if [ -z "$arg" ]; then die usage "play 需要关键词、歌曲 id 或歌单链接" 2; fi
|
||
local one id rc=0
|
||
one="$(api_or_die song "$arg" 2>/dev/null)" || rc=$?
|
||
if [ $rc -ne 0 ]; then # 纯数字又说「没这首歌」:多半给的是歌单 id
|
||
case "$arg" in
|
||
*[!0-9]*) api_or_die song "$arg" >/dev/null ;; # 非数字:把原样报错交给用户
|
||
*) cmd_playlist "$arg" ;;
|
||
esac
|
||
return
|
||
fi
|
||
id="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' <<<"$one")"
|
||
local songs; songs="$(api_out song "$id")" || exit $?
|
||
python3 -c 'import json,sys;s=json.load(sys.stdin);print(json.dumps([s]))' <<<"$songs" | build_queue >/dev/null || exit $?
|
||
play_queue
|
||
}
|
||
cmd_play_ids() {
|
||
local j index
|
||
[ "${#ARGS[@]}" -eq 1 ] || die usage "用法:play-ids <id,id,...> [--start N]" 2
|
||
[ "$SHUFFLE" = 0 ] || die usage "play-ids 按输入顺序播放,不支持 --shuffle" 2
|
||
j="$(api_out play-ids "${ARGS[0]}" --start "$START")" || exit $?
|
||
python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue "$START" >/dev/null || exit $?
|
||
index="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["start"] - 1)' "$QUEUE")"
|
||
mpvctl_or_die shuffle off >/dev/null
|
||
kv start "$START"
|
||
play_queue --index "$index"
|
||
}
|
||
cmd_remove() {
|
||
local n="${ARGS[0]:-}" stage rc result
|
||
[ "${#ARGS[@]}" -eq 1 ] && [[ "$n" =~ ^[0-9]+$ ]] && [[ "$n" =~ [1-9] ]] || die usage "remove 需要队列序号(从 1 开始)" 2
|
||
mkdir -p "$LOCAL_DIR"
|
||
stage="$(mktemp -d "$LOCAL_DIR/.queue-edit.XXXXXX")" || exit 5
|
||
mpvctl playlist > "$stage/playlist.json"
|
||
rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; exit "$rc"; }
|
||
python3 "$SKILL_DIR/scripts/lib/queue-edit.py" "$LOCAL_DIR" "$stage/playlist.json" "$n" "$stage"
|
||
rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; exit "$rc"; }
|
||
result="$(mpvctl remove "$n")"
|
||
rc=$?; [ "$rc" -eq 0 ] || { rm -rf "$stage"; exit "$rc"; }
|
||
keepalive_stop
|
||
local file
|
||
for file in queue.json queue.m3u8 songs.json urls.txt; do
|
||
mv "$stage/$file" "$LOCAL_DIR/$file" || { rm -rf "$stage"; exit 5; }
|
||
done
|
||
rm -rf "$stage"
|
||
if [ "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$result")" -gt 0 ]; then keepalive_start; fi
|
||
kv command remove; kv removed "$n"
|
||
emit_status "已移除队列第 $n 首"
|
||
}
|
||
cmd_clear() {
|
||
[ "${#ARGS[@]}" -eq 0 ] || die usage "用法:clear" 2
|
||
mpvctl_or_die clear >/dev/null
|
||
keepalive_stop
|
||
rm -f "$QUEUE" "$M3U" "$LOCAL_DIR/songs.json" "$LOCAL_DIR/urls.txt" "$LOCAL_DIR/urls_at"
|
||
kv command clear
|
||
emit_status "已清空队列"
|
||
}
|
||
cmd_search() {
|
||
local j; j="$(api_out search "$KEYWORD" --limit "$LIMIT")" || exit $?
|
||
kv_api_total "$j"
|
||
local list count
|
||
list="$(python3 -c 'import json,sys
|
||
for i, s in enumerate(json.load(sys.stdin)["songs"], 1):
|
||
print("%3d. %s — %s [%s] %d:%02d%s id=%s" % (i, s["name"], s["artist"], s["album"], s["ms"]//60000, s["ms"]//1000%60, " VIP" if s["vip"] else "", s["id"]))' <<<"$j")"
|
||
count="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
kv count "$count"; kv kw "$KEYWORD"; kv source api; kv as_of "$(now)"
|
||
flush_result "搜到 $count 条:$KEYWORD"
|
||
[ "$JSON" = 1 ] || printf '%s\n' "$list"
|
||
}
|
||
emit_status() { # emit_status [首行覆盖]:读 mpv 真值并打印状态块
|
||
local st cur; st="$(mpvctl_or_die status)"
|
||
cur="$(cur_song_json)"
|
||
python3 - "$st" "$QUEUE" "$cur" <<'PY' > "${TMPDIR:-/tmp}/nm_status.$$"
|
||
import json, sys
|
||
st = json.loads(sys.argv[1]); q = {}
|
||
try: q = json.load(open(sys.argv[2]))
|
||
except Exception: pass
|
||
songs = q.get('songs', [])
|
||
try: s = json.loads(sys.argv[3]) if sys.argv[3].strip() else None
|
||
except Exception: s = None
|
||
if s is None: # 映射不到(旧格式/异常)就退回按位置取
|
||
s = songs[st['pos']] if 0 <= st['pos'] < len(songs) else None
|
||
print("HEAD\t" + ("正在播放:" + s['name'] + " - " + s['artist'] if s and st['playing'] else
|
||
"已暂停:" + s['name'] + " - " + s['artist'] if s and st['paused'] else
|
||
"没有在放" if st['idle'] else "播放中"))
|
||
if s:
|
||
print(f"title\t{s['name']}"); print(f"artist\t{s['artist']}"); print(f"song_id\t{s['id']}")
|
||
print("state\t" + ("idle" if st['idle'] else "playing" if st['playing'] else "paused" if st['paused'] else "idle"))
|
||
if st['count']: print(f"queue_pos\t{st['pos']+1}/{st['count']}")
|
||
if st['elapsed'] is not None: print(f"elapsed\t{st['elapsed']}")
|
||
if st['duration'] is not None: print(f"duration\t{st['duration']}")
|
||
print(f"volume\t{st['volume']}"); print("repeat\t" + str(st['repeat']).lower()); print("shuffle\t" + str(st['shuffle']).lower())
|
||
print("repeat_mode\t" + st['repeat_mode'])
|
||
print("source\tmpv")
|
||
PY
|
||
local head; head="${1:-$(head -1 "${TMPDIR:-/tmp}/nm_status.$$" | cut -f2-)}"
|
||
while IFS=$'\t' read -r k v; do [ "$k" = HEAD ] || kv "$k" "$v"; done < "${TMPDIR:-/tmp}/nm_status.$$"
|
||
rm -f "${TMPDIR:-/tmp}/nm_status.$$"
|
||
kv as_of "$(now)"
|
||
flush_result "$head"
|
||
}
|
||
cmd_status() { emit_status; }
|
||
current_id() {
|
||
local id; id="$(cur_id)"
|
||
[ -n "$id" ] || die notfound "取不到当前歌曲(队列信息丢了),先 play/daily 一下" 4
|
||
printf '%s' "$id"
|
||
}
|
||
cmd_myplaylists() { # 我创建 + 我收藏的歌单;[collected|own] 只列其中一类
|
||
local mode="${1:-}" j list n
|
||
j="$(api_out myplaylists --limit "${LIMIT:-100}")" || exit $?
|
||
kv_api_total "$j"
|
||
n="$(python3 -c 'import json,sys
|
||
p = json.load(sys.stdin)["playlists"]; m = sys.argv[1] if len(sys.argv) > 1 else ""
|
||
print(sum(1 for x in p if (m != "collected" or x.get("sub")) and (m != "own" or not x.get("sub"))))' "$mode" <<<"$j")"
|
||
list="$(python3 -c 'import json,sys
|
||
m = sys.argv[1] if len(sys.argv) > 1 else ""
|
||
for p in json.load(sys.stdin)["playlists"]:
|
||
if m == "collected" and not p.get("sub"): continue
|
||
if m == "own" and p.get("sub"): continue
|
||
print(" %s %s(%d 首)%s" % (p["id"], p["name"], p["count"], " [收藏的]" if p.get("sub") else ""))' "$mode" <<<"$j")"
|
||
kv count "$n"; kv source api; kv as_of "$(now)"
|
||
flush_result "我的歌单($n 张${mode:+ · $mode})"
|
||
[ "$JSON" = 1 ] || printf '%s\n' "$list"
|
||
}
|
||
|
||
cmd_like() {
|
||
local id name flag=true cur
|
||
[ "${1:-}" = "--unlike" ] && flag=false
|
||
cur="$(cur_song_json)"
|
||
if [ -n "$cur" ]; then
|
||
id="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' <<<"$cur")"
|
||
name="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])' <<<"$cur")"
|
||
else
|
||
id=""; name=""
|
||
fi
|
||
[ -n "$id" ] || die notfound "取不到当前歌曲(队列信息丢了),先 play/daily 一下" 4
|
||
if [ "$flag" = true ]; then api_or_die like "$id" >/dev/null; else api_or_die like "$id" --unlike >/dev/null; fi
|
||
local now_liked lj; lj="$(api_out like-state "$id")" || exit $?
|
||
now_liked="$(python3 -c 'import json,sys;print(str(json.load(sys.stdin)["liked"]).lower())' <<<"$lj")"
|
||
kv song_id "$id"; kv title "$name"; kv liked "$now_liked"; kv as_of "$(now)"
|
||
if [ "$now_liked" = true ]; then flush_result "已加入「我喜欢的音乐」:$name"; else flush_result "已移出「我喜欢的音乐」:$name"; fi
|
||
}
|
||
cmd_liked_ids() {
|
||
local j ids count
|
||
[ "${#ARGS[@]}" -eq 0 ] || die usage "用法:liked-ids" 2
|
||
j="$(api_out liked-ids)" || exit $?
|
||
if [ "$JSON" = 1 ]; then printf '%s\n' "$j"; return; fi
|
||
count="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
ids="$(python3 -c 'import json,sys;print(",".join(map(str,json.load(sys.stdin)["ids"])))' <<<"$j")"
|
||
kv count "$count"; kv ids "$ids"; kv_api_total "$j"; kv source api; kv as_of "$(now)"
|
||
flush_result "我喜欢的歌曲 ID($count 首)"
|
||
}
|
||
cmd_dislike() {
|
||
local id="${ARGS[0]:-}" j
|
||
[ "${#ARGS[@]}" -le 1 ] || die usage "用法:dislike [歌曲id]" 2
|
||
if [ -z "$id" ]; then id="$(current_id)" || exit $?; fi
|
||
j="$(api_out dislike "$id")" || exit $?
|
||
kv song_id "$id"; kv disliked true; kv source api; kv as_of "$(now)"
|
||
flush_result "已加入私人 FM 不喜欢:$id"
|
||
}
|
||
cmd_lyric() {
|
||
local id; id="$(current_id)"
|
||
local j; j="$(api_out lyric "$id" ${1:-})" || exit $?
|
||
if [ "$JSON" = 1 ]; then
|
||
kv lyric "$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("lyric",""))' <<<"$j")"
|
||
kv song_id "$id"; kv as_of "$(now)"
|
||
flush_result "歌词已取到(id=${id})"
|
||
return
|
||
fi
|
||
if [ "${1:-}" = "--all" ]; then
|
||
local name; name="$(song_by_id "$id" | python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])')"
|
||
kv song_id "$id"; kv title "$name"; kv source api; kv as_of "$(now)"
|
||
flush_result "歌词:$name"
|
||
python3 -c 'import json,sys;print(json.load(sys.stdin)["lyric"])' <<<"$j"
|
||
return
|
||
fi
|
||
local pos
|
||
pos="$(mpvctl_or_die status | python3 -c 'import json,sys;print(json.load(sys.stdin).get("elapsed") or 0)')"
|
||
printf '%s' "$j" > "${TMPDIR:-/tmp}/nm_lyric.$$"
|
||
python3 - "$pos" "${TMPDIR:-/tmp}/nm_lyric.$$" <<'PY'
|
||
import json, re, sys
|
||
cur = float(sys.argv[1])
|
||
lyric = json.load(open(sys.argv[2], encoding="utf-8"))["lyric"]
|
||
lines = []
|
||
for ln in lyric.splitlines():
|
||
m = re.match(r"\[(\d+):(\d+(?:\.\d+)?)\](.*)", ln)
|
||
if m and m.group(3).strip():
|
||
lines.append((int(m.group(1)) * 60 + float(m.group(2)), m.group(3).strip()))
|
||
lines.sort()
|
||
if not lines:
|
||
print("(纯音乐,没有歌词)")
|
||
else:
|
||
i = max([k for k, (t, _) in enumerate(lines) if t <= cur] or [0])
|
||
print("当前句:" + lines[i][1])
|
||
if i + 1 < len(lines):
|
||
print("下一句:" + lines[i + 1][1])
|
||
PY
|
||
rm -f "${TMPDIR:-/tmp}/nm_lyric.$$"
|
||
}
|
||
cmd_setup() { # setup [--cookie 值] [--quality 级别];不带参数就告诉你怎么登录
|
||
local did=""
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--cookie) conf_set cookie "$2"; did="$did cookie"; shift ;;
|
||
--quality) conf_set quality "$2"; did="$did quality" ;;
|
||
esac
|
||
shift
|
||
done
|
||
if [ -z "$did" ]; then
|
||
kv config "$CFG"
|
||
kv how "netease-music login(推荐,手机扫码 30 秒);或 setup --cookie 'MUSIC_U=…' 粘贴浏览器里的登录 cookie"
|
||
kv as_of "$(now)"
|
||
flush_result "netease-music 要登录才能用(每日推荐/我喜欢的/点播 VIP):跑 netease-music login"
|
||
return
|
||
fi
|
||
kv saved "$did"; kv config "$CFG"; kv as_of "$(now)"
|
||
flush_result "配置已写入:$did"
|
||
}
|
||
|
||
mask_phone() { local p="$1"; [ ${#p} -ge 8 ] && printf '%s****%s' "${p:0:3}" "${p: -4}" || printf '%s' "$p"; }
|
||
|
||
cmd_login() { # 登录:默认扫码(过期自动换码);--sms 手机号 / --sms-code 走短信验证码
|
||
need_deps
|
||
local attempt=0 j key url png res state cookie sess nick uid
|
||
if [ -n "$SMS_PHONE" ]; then
|
||
if [ -n "$SMS_CODE" ]; then
|
||
res="$(api_out sms-login "$SMS_PHONE" "$SMS_CODE")" || exit $?
|
||
cookie="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["cookie"])' <<<"$res")"
|
||
conf_set cookie "$cookie"; COOKIE="$cookie"
|
||
sess="$(api_out session)" || exit $?
|
||
nick="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["nick"])' <<<"$sess")"
|
||
uid="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["uid"])' <<<"$sess")"
|
||
kv nick "$nick"; kv uid "$uid"; kv config "$CFG"; kv as_of "$(now)"
|
||
flush_result "登录成功:${nick}(cookie 已存 ${CFG})"
|
||
else
|
||
api_or_die sms-send "$SMS_PHONE" >/dev/null
|
||
kv phone "$(mask_phone "$SMS_PHONE")"; kv as_of "$(now)"
|
||
flush_result "验证码已发到 $(mask_phone "$SMS_PHONE"):拿到后跑 netease-music login --sms $SMS_PHONE --sms-code <验证码>"
|
||
fi
|
||
return
|
||
fi
|
||
while [ "$attempt" -lt "$REFRESH" ]; do
|
||
attempt=$((attempt + 1))
|
||
j="$(api_out qr-start)" || exit $?
|
||
key="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["key"])' <<<"$j")"
|
||
url="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["url"])' <<<"$j")"
|
||
png="/tmp/netease-login-qr-${key:0:8}.png"
|
||
rm -f /tmp/netease-login-qr-*.png # 旧的码一律删掉,避免扫到作废的那张
|
||
osascript -e 'tell application "Preview" to close (every window whose name contains "netease-login-qr")' >/dev/null 2>&1 || true
|
||
if command -v qrencode >/dev/null 2>&1; then
|
||
qrencode -o "$png" -s 8 -m 2 "$url"
|
||
cp "$png" /tmp/netease-login-qr.png # 稳定路径,方便找
|
||
qrencode -t UTF8 -m 1 "$url"
|
||
open -a Preview "$png" 2>/dev/null || open "$png" 2>/dev/null || true
|
||
else
|
||
kv qr_url "$url"
|
||
fi
|
||
echo "第 $attempt/$REFRESH 张码:${png}(刚弹到最前面)。用网易云 App 右上角「扫一扫」扫它,扫完在手机上点「确认登录」。过期自动换新。" >&2
|
||
res="$(api_out qr-poll "$key" --timeout "$TIMEOUT")" || exit $?
|
||
state="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["state"])' <<<"$res")"
|
||
if [ "$state" = "ok" ]; then
|
||
cookie="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["cookie"])' <<<"$res")"
|
||
conf_set cookie "$cookie"
|
||
COOKIE="$cookie"
|
||
local sess nick uid
|
||
sess="$(api_out session)" || exit $?
|
||
nick="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["nick"])' <<<"$sess")"
|
||
uid="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["uid"])' <<<"$sess")"
|
||
kv nick "$nick"; kv uid "$uid"; kv config "$CFG"; kv as_of "$(now)"
|
||
flush_result "登录成功:${nick}(cookie 已存 ${CFG})"
|
||
return
|
||
fi
|
||
[ "$state" = "expired" ] && echo "这张码过期了,自动换一张…" >&2 || die timeout "扫码没完成(state=${state})" 5
|
||
done
|
||
die timeout "连着 $REFRESH 张码都没扫成:重跑 netease-music login" 5
|
||
}
|
||
cmd_check() {
|
||
local ok=true b
|
||
for b in node python3 mpv qrencode; do
|
||
if command -v "$b" >/dev/null 2>&1; then kv "bin_$b" ok; else kv "bin_$b" missing; ok=false; fi
|
||
done
|
||
kv quality "$QUALITY"
|
||
kv cookie "$([ -n "$COOKIE" ] && echo "已配置(${#COOKIE} 字节)" || echo 未配置)"
|
||
[ -n "$COOKIE" ] || ok=false
|
||
if command -v node >/dev/null 2>&1; then
|
||
local sess; sess="$(api session 2>/dev/null)"
|
||
if [ -n "$sess" ]; then
|
||
kv logged_in "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["loggedIn"])' <<<"$sess" 2>/dev/null || echo unknown)"
|
||
kv nick "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["nick"])' <<<"$sess" 2>/dev/null || echo '')"
|
||
kv uid "$(python3 -c 'import json,sys;print(json.load(sys.stdin)["uid"])' <<<"$sess" 2>/dev/null || echo 0)"
|
||
fi
|
||
kv mpv "$(mpvctl status >/dev/null 2>&1 && echo ok || echo 起不来)"
|
||
fi
|
||
kv as_of "$(now)"
|
||
[ "$ok" = true ] || { flush_result "自检未全绿(看下面字段)"; die deps "未登录跑 netease-music login;缺依赖用 brew install mpv qrencode" 3; }
|
||
flush_result "netease-music 自检通过:接口与播放器都可用"
|
||
}
|
||
|
||
apply_shuffle() { [ "${SHUFFLE:-0}" = 1 ] && mpvctl shuffle on >/dev/null; }
|
||
|
||
# ---------- 地址保活:直链约 5 分钟过期,长队列边走边换 ----------
|
||
# 建队时地址是新鲜的(起播零等待);这里后台盯着,快过期就把「还没播到的部分」批量重取、
|
||
# 原地换掉 m3u 里对应的行。KEEPALIVE_PID 单独存,换队列时旧的就收掉。
|
||
KEEPALIVE_PID="$LOCAL_DIR/keepalive.pid"
|
||
KEEPALIVE_LOG="$LOCAL_DIR/keepalive.log"
|
||
|
||
keepalive_stop() {
|
||
[ -s "$KEEPALIVE_PID" ] || return 0
|
||
local pid; pid="$(cat "$KEEPALIVE_PID" 2>/dev/null)"
|
||
case "$pid" in ''|*[!0-9]*) ;; *) kill "$pid" 2>/dev/null ;; esac
|
||
rm -f "$KEEPALIVE_PID"
|
||
}
|
||
|
||
keepalive_start() {
|
||
keepalive_stop
|
||
nohup "$SKILL_DIR/scripts/refresh-urls.sh" > "$KEEPALIVE_LOG" 2>&1 &
|
||
echo $! > "$KEEPALIVE_PID"
|
||
}
|
||
|
||
# ---------- 队列现场:快照 / 切回 / 追加 ----------
|
||
# 一个 mpv 只有一条队列,任何「放 X」都会整列替换;所以建新队列之前,
|
||
# 自动把「当前这条」存成上一条快照(只存歌曲列表,地址回放时重取)。
|
||
QUEUES_DIR="$LOCAL_DIR/queues"
|
||
SKIP_SNAPSHOT=0
|
||
|
||
mpv_meta() { # 输出 "<pos> <shuffle>";拿不到就给 "-1 false"
|
||
local st; st="$(mpvctl status 2>/dev/null)" || { printf '%s' "-1 false"; return; }
|
||
python3 -c 'import json,sys
|
||
try:
|
||
d = json.loads(sys.argv[1]); print("%d %s" % (d.get("pos", -1), str(d.get("shuffle", False)).lower()))
|
||
except Exception: print("-1 false")' "$st"
|
||
}
|
||
|
||
snap_auto() { # 建新队列前留一份上一条(出错也不影响主流程)
|
||
[ -s "$QUEUE" ] || return 0
|
||
local m id; m="$(mpv_meta)"; id="$(cur_id)"
|
||
python3 "$SKILL_DIR/scripts/lib/snapshot.py" save "$QUEUE" "$QUEUES_DIR/prev.json" \
|
||
"${m%% *}" "${m##* }" "上一条" "${id:-}" >/dev/null 2>&1 || true
|
||
}
|
||
|
||
snap_save() { # snap_save <文件> <名字> [队列文件]:打一份快照
|
||
local m id; m="$(mpv_meta)"; id="$(cur_id)"
|
||
python3 "$SKILL_DIR/scripts/lib/snapshot.py" save "${3:-$QUEUE}" "$1" \
|
||
"${m%% *}" "${m##* }" "$2" "${id:-}"
|
||
}
|
||
|
||
restore_snapshot() { # restore_snapshot <快照文件>:按快照重建队列,并回到当时的歌与随机状态
|
||
local snap="$1" meta songs pos shuf sid
|
||
meta="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" meta "$snap")" || die external "快照读不了:$snap" 5
|
||
songs="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" songs "$snap")"
|
||
pos="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("pos",-1))' <<<"$meta")"
|
||
shuf="$(python3 -c 'import json,sys;print(str(json.load(sys.stdin).get("shuffle",False)).lower())' <<<"$meta")"
|
||
sid="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("song_id") or "")' <<<"$meta")"
|
||
SKIP_SNAPSHOT=1
|
||
printf '%s' "$songs" | build_queue >/dev/null || exit $?
|
||
SKIP_SNAPSHOT=0
|
||
[ "$shuf" = true ] && mpvctl_or_die shuffle on >/dev/null || mpvctl_or_die shuffle off >/dev/null
|
||
# 输出交给调用方(back / queue-load 会自己 emit_status),这里只负责把歌放起来
|
||
# 开随机后「当时的位置」没有意义:优先回到当时在放的那首歌;旧快照没存 id 才退回位置
|
||
if [ -n "$sid" ]; then
|
||
play_queue >/dev/null
|
||
local idx; idx="$(jump_index_of_id "$sid")"
|
||
[ -n "$idx" ] && mpvctl_or_die jump "$idx" >/dev/null
|
||
elif [ "${pos:-0}" -gt 0 ] 2>/dev/null; then play_queue --index "$pos" >/dev/null; else play_queue >/dev/null; fi
|
||
}
|
||
|
||
cmd_queues() { # 列出本地队列快照
|
||
local rows n list
|
||
rows="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" list "$QUEUES_DIR")"
|
||
n="$(python3 -c 'import json,sys;print(len(json.load(sys.stdin)))' <<<"$rows")"
|
||
list="$(python3 -c 'import json,sys
|
||
for r in json.load(sys.stdin):
|
||
tag = "上一条(back 切回)" if r["file"] == "prev.json" else r["name"]
|
||
print(" %s %d 首 %s %s" % (r["file"], r["count"], (r["saved_at"] or "")[:16].replace("T", " "), tag))' <<<"$rows")"
|
||
kv count "$n"; kv source local; kv as_of "$(now)"
|
||
flush_result "本地队列快照($n 条)"
|
||
[ "$JSON" = 1 ] || [ "$n" = 0 ] && { [ "$JSON" = 1 ] || printf ' (还没有快照;每次放新的音乐会自己留一条「上一条」)\n'; } || printf '%s\n' "$list"
|
||
}
|
||
|
||
cmd_queue_save() { # 把当前队列存成命名快照
|
||
[ -s "$QUEUE" ] || die notfound "现在没有队列可存" 4
|
||
local name="$1" fname cnt out
|
||
fname="$(python3 -c 'import re,sys
|
||
n = (sys.argv[1] or "").strip(); n = re.sub(r"[^\w.\-]+", "_", n, flags=re.UNICODE)[:40]
|
||
print(n)' "$name")"
|
||
[ -n "$fname" ] || fname="save-$(date +%Y%m%d-%H%M%S)"
|
||
out="$(snap_save "$QUEUES_DIR/$fname.json" "${name:-$fname}")" || die external "存快照失败" 5
|
||
cnt="$(printf '%s' "$out" | cut -f2)"
|
||
kv saved "$fname"; kv count "$cnt"; kv source local; kv as_of "$(now)"
|
||
flush_result "已存队列快照:${fname}($cnt 首)"
|
||
}
|
||
|
||
cmd_back() { # 切回上一条队列(含当时的位置;再 back 一次就切回来)
|
||
[ -s "$QUEUES_DIR/prev.json" ] || die notfound "没有上一条可切回(用 queues 看有哪些快照)" 4
|
||
local tmp="$QUEUES_DIR/.swap.json" meta name count
|
||
[ -s "$QUEUE" ] && snap_save "$tmp" "下一条" >/dev/null 2>&1
|
||
meta="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" meta "$QUEUES_DIR/prev.json")"
|
||
name="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("name") or "")' <<<"$meta")"
|
||
count="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("count") or 0)' <<<"$meta")"
|
||
kv back_to "$name"; kv count "$count"
|
||
restore_snapshot "$QUEUES_DIR/prev.json"
|
||
[ -s "$tmp" ] && mv "$tmp" "$QUEUES_DIR/prev.json"
|
||
emit_status "已切回上一条:${name}($count 首)"
|
||
}
|
||
|
||
cmd_queue_load() { # 载入某个命名快照(当前这条会留成「上一条」,可 back 回来)
|
||
local name="$1"; [ -n "$name" ] || die usage "queue-load 需要快照名(用 queues 看有哪些)" 2
|
||
local f="$QUEUES_DIR/$(basename "$name")"
|
||
[ -s "$f" ] || f="$QUEUES_DIR/$(basename "$name").json"
|
||
[ -s "$f" ] || die notfound "没有这个快照:${name}(queues 看有哪些)" 4
|
||
local meta count
|
||
meta="$(python3 "$SKILL_DIR/scripts/lib/snapshot.py" meta "$f")" || die external "快照读不了:$f" 5
|
||
count="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("count") or 0)' <<<"$meta")"
|
||
[ -s "$QUEUE" ] && snap_save "$QUEUES_DIR/prev.json" "上一条" >/dev/null 2>&1
|
||
kv loaded "$(basename "$f" .json)"; kv count "$count"
|
||
restore_snapshot "$f"
|
||
emit_status "已载入快照:$(basename "$f" .json)($count 首)"
|
||
}
|
||
|
||
cmd_add() { # 往当前队列尾部加一首(不动正在放的那首)
|
||
local q="$1"; [ -n "$q" ] || die usage "add 需要歌曲关键词或 id(例:netease-music add 幻听)" 2
|
||
local song id name artist ms
|
||
song="$(api_out song "$q")" || exit $?
|
||
id="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' <<<"$song")"
|
||
name="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["name"])' <<<"$song")"
|
||
artist="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["artist"])' <<<"$song")"
|
||
ms="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("ms") or 0)' <<<"$song")"
|
||
if [ ! -s "$QUEUE" ] || [ ! -s "$M3U" ]; then # 还没队列:这一首就是队列
|
||
python3 -c 'import json,sys;print(json.dumps([json.load(sys.stdin)]))' <<<"$song" | build_queue >/dev/null || exit $?
|
||
play_queue; return
|
||
fi
|
||
local raw url
|
||
raw="$(api_out url "$id" --level "$QUALITY")" || exit $?
|
||
url="$(python3 -c 'import json,sys
|
||
d = json.load(sys.stdin); it = (d.get("items") or [{}])[0]; print(it.get("url") or "")' <<<"$raw")"
|
||
[ -n "$url" ] || die notfound "这首拿不到播放地址(可能要 VIP 或已下架):$name" 4
|
||
local tmp="${TMPDIR:-/tmp}/nm_add.$$.m3u" res ok cnt
|
||
res="$(python3 "$SKILL_DIR/scripts/lib/queue-append.py" "$QUEUE" "$M3U" "$tmp" "$id" "$name" "$artist" "$ms" "$url")"
|
||
ok="$(python3 -c 'import json,sys;print(str(json.load(sys.stdin).get("ok",False)).lower())' <<<"$res")"
|
||
if [ "$ok" != true ]; then
|
||
rm -f "$tmp"
|
||
cnt="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("count",0))' <<<"$res")"
|
||
kv added no; kv count "$cnt"; kv source local; kv as_of "$(now)"
|
||
flush_result "没加:$name - $artist 已经在队列里(队列 $cnt 首)"
|
||
return
|
||
fi
|
||
mpvctl_or_die append "$tmp" >/dev/null; rm -f "$tmp"
|
||
cnt="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("count",0))' <<<"$res")"
|
||
kv added "$name - $artist"; kv count "$cnt"
|
||
emit_status "已加到队尾:$name - ${artist}(队列 $cnt 首)"
|
||
}
|
||
|
||
# ---------- 歌单搜索 / 榜单 / 收藏 / 私人 FM / 最近 / 播客 ----------
|
||
|
||
cmd_playlists() { # 搜歌单(别人做的),只列不播
|
||
local kw="${ARGS[0]:-}"; [ -n "$kw" ] || die usage "playlists 需要关键词(例:netease-music playlists 夜跑)" 2
|
||
local j list n
|
||
j="$(api_out playlists "$kw" --limit "${LIMIT:-20}")" || exit $?
|
||
kv_api_total "$j"
|
||
n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
list="$(python3 -c 'import json,sys
|
||
for p in json.load(sys.stdin)["playlists"]:
|
||
print(" %s %s(%d 首 · %s)%s" % (p["id"], p["name"], p["count"], p["creator"], " [已收藏]" if p["sub"] else ""))' <<<"$j")"
|
||
kv count "$n"; kv source api; kv as_of "$(now)"
|
||
flush_result "歌单搜索「${kw}」($n 张)"
|
||
[ "$JSON" = 1 ] || printf '%s\n' "$list"
|
||
}
|
||
|
||
cmd_charts() { # 列排行榜
|
||
local j list n
|
||
j="$(api_out charts --limit "${LIMIT:-100}")" || exit $?
|
||
kv_api_total "$j"
|
||
n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
list="$(python3 -c 'import json,sys
|
||
for c in json.load(sys.stdin)["charts"]:
|
||
print(" %s %s(%s 首 · %s)" % (c["id"], c["name"], c["count"], c["freq"]))' <<<"$j")"
|
||
kv count "$n"; kv source api; kv as_of "$(now)"
|
||
flush_result "排行榜($n 个)"
|
||
[ "$JSON" = 1 ] || printf '%s\n' "$list"
|
||
}
|
||
|
||
cmd_chart() { # 放某个榜单(名字或 id)
|
||
local q="$1"; [ -n "$q" ] || die usage "chart 需要榜单名(例:netease-music chart 飙升榜;全部榜单用 charts)" 2
|
||
local j
|
||
j="$(api_out chart "$q" --limit "${LIMIT:-500}")" || exit $?
|
||
kv_api_total "$j"
|
||
kv chart "$(python3 -c 'import json,sys;d=json.load(sys.stdin);print(d.get("chart") or "")' <<<"$j")"
|
||
python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue >/dev/null || exit $?
|
||
apply_shuffle
|
||
play_queue
|
||
}
|
||
|
||
cmd_myalbums() { # 我收藏的专辑
|
||
local j list n
|
||
j="$(api_out myalbums --limit "${LIMIT:-60}")" || exit $?
|
||
kv_api_total "$j"
|
||
n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
list="$(python3 -c 'import json,sys
|
||
for a in json.load(sys.stdin)["albums"]:
|
||
print(" %s %s(%s 首 · %s)" % (a["id"], a["name"], a["size"], a["artist"]))' <<<"$j")"
|
||
kv count "$n"; kv source api; kv as_of "$(now)"
|
||
flush_result "我收藏的专辑($n 张)"
|
||
[ "$JSON" = 1 ] || printf '%s\n' "$list"
|
||
}
|
||
|
||
cmd_myartists() { # 我收藏的歌手
|
||
local j list n
|
||
j="$(api_out myartists --limit "${LIMIT:-60}")" || exit $?
|
||
kv_api_total "$j"
|
||
n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
list="$(python3 -c 'import json,sys
|
||
for a in json.load(sys.stdin)["artists"]:
|
||
print(" %s %s(专辑 %s 张)" % (a["id"], a["name"], a["albums"]))' <<<"$j")"
|
||
kv count "$n"; kv source api; kv as_of "$(now)"
|
||
flush_result "我收藏的歌手($n 位)"
|
||
[ "$JSON" = 1 ] || printf '%s\n' "$list"
|
||
}
|
||
|
||
cmd_fm() { # 私人 FM
|
||
local j
|
||
j="$(api_out fm --limit "${LIMIT:-12}")" || exit $?
|
||
python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue >/dev/null || exit $?
|
||
apply_shuffle
|
||
play_queue
|
||
}
|
||
|
||
cmd_recent() { # 最近播放(只列)
|
||
local j list n
|
||
j="$(api_out recent --limit "${LIMIT:-30}")" || exit $?
|
||
kv_api_total "$j"
|
||
n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
list="$(python3 -c 'import json,sys
|
||
for i, s in enumerate(json.load(sys.stdin)["songs"], 1):
|
||
print(" %2d %s - %s(播放 %s 次)" % (i, s["name"], s["artist"], s.get("plays", "?")))' <<<"$j")"
|
||
kv count "$n"; kv source api; kv as_of "$(now)"
|
||
flush_result "最近播放($n 首)"
|
||
[ "$JSON" = 1 ] || printf '%s\n' "$list"
|
||
}
|
||
|
||
cmd_replay() { # 再放一次最近第 N 首(默认 1 = 最近那首)
|
||
local n="${1:-1}" j id
|
||
j="$(api_out recent --limit 30)" || exit $?
|
||
id="$(python3 -c 'import json,sys
|
||
s = json.load(sys.stdin)["songs"]; i = int(sys.argv[1]) - 1
|
||
print(s[i]["id"] if 0 <= i < len(s) else "")' "$n" <<<"$j")"
|
||
[ -n "$id" ] || die notfound "最近播放里没有第 $n 首" 4
|
||
cmd_play "$id"
|
||
}
|
||
|
||
cmd_podcasts() { # 我的播客订阅(--recommend 看推荐的)
|
||
local args=(podcasts --limit "${LIMIT:-50}") j list n
|
||
[ "${RECOMMEND:-0}" = 1 ] && args+=(--recommend)
|
||
j="$(api_out "${args[@]}")" || exit $?
|
||
kv_api_total "$j"
|
||
n="$(python3 -c 'import json,sys;print(json.load(sys.stdin)["count"])' <<<"$j")"
|
||
list="$(python3 -c 'import json,sys
|
||
for p in json.load(sys.stdin)["podcasts"]:
|
||
print(" %s %s(%s 期 · %s)" % (p["id"], p["name"], p["episodes"], p["dj"]))' <<<"$j")"
|
||
kv count "$n"; kv source api; kv as_of "$(now)"
|
||
flush_result "我的播客($n 个)"
|
||
[ "$JSON" = 1 ] || printf '%s\n' "$list"
|
||
}
|
||
|
||
cmd_podcast() { # 放某播客最新 N 期
|
||
local q="$1"; [ -n "$q" ] || die usage "podcast 需要播客名或 id(例:netease-music podcast 黑水公园 --limit 3)" 2
|
||
local j
|
||
j="$(api_out podcast "$q" --limit "${LIMIT:-3}")" || exit $?
|
||
kv_api_total "$j"
|
||
kv podcast "$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("name") or "")' <<<"$j")"
|
||
python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue >/dev/null || exit $?
|
||
play_queue
|
||
}
|
||
|
||
cmd_episode() { # 放某一期节目(要节目 id,从 podcast 列表里抄)
|
||
local q="$1"; [ -n "$q" ] || die usage "episode 需要节目 id(从 netease-music podcast <播客> 的输出里抄)" 2
|
||
local j
|
||
j="$(api_out episode "$q")" || exit $?
|
||
kv episode "$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("name") or "")' <<<"$j")"
|
||
python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["songs"]))' <<<"$j" | build_queue >/dev/null || exit $?
|
||
play_queue
|
||
}
|
||
|
||
# ---------- 入口 ----------
|
||
CMD="${1:-status}"
|
||
case "$CMD" in -h|--help) CMD=help ;; -V|--version) CMD=version ;; esac
|
||
if [ $# -gt 0 ]; then shift; fi
|
||
ARGS=(); LIMIT=""; OFFSET=0; START=1; KEYWORD=""; QUALITY=""; TIMEOUT=300; REFRESH=4; SHUFFLE=0; SMS_PHONE=""; SMS_CODE=""; RECOMMEND=0
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--json) JSON=1 ;;
|
||
--quiet|-q) QUIET=1 ;;
|
||
--limit) LIMIT="$2"; shift ;;
|
||
--offset)
|
||
[ $# -gt 1 ] && [[ "$2" =~ ^[0-9]+$ ]] || die usage "--offset 需要非负整数" 2
|
||
OFFSET="$2"; shift ;;
|
||
--start)
|
||
[ "$CMD" = play-ids ] && [ $# -gt 1 ] && [[ "$2" =~ ^[0-9]+$ ]] || die usage "play-ids --start 需要从 1 开始的序号" 2
|
||
START="$2"; shift ;;
|
||
--shuffle) SHUFFLE=1 ;;
|
||
--recommend) RECOMMEND=1 ;;
|
||
--timeout) TIMEOUT="$2"; shift ;;
|
||
--refresh) REFRESH="$2"; shift ;;
|
||
--sms) SMS_PHONE="$2"; shift ;;
|
||
--sms-code) SMS_CODE="$2"; shift ;;
|
||
*) ARGS+=("$1") ;;
|
||
esac
|
||
shift
|
||
done
|
||
|
||
COOKIE="$(env_get NETEASE_MUSIC_COOKIE)"; [ -z "$COOKIE" ] && COOKIE="$(env_get PI_SKILL_NETEASE_MUSIC_COOKIE)"; [ -z "$COOKIE" ] && COOKIE="$(conf_get cookie)"
|
||
QUALITY="$(env_get NETEASE_MUSIC_QUALITY)"; [ -z "$QUALITY" ] && QUALITY="$(env_get PI_SKILL_NETEASE_MUSIC_QUALITY)"; [ -z "$QUALITY" ] && QUALITY="$(conf_get quality)"; [ -z "$QUALITY" ] && QUALITY=lossless
|
||
KEYWORD="${ARGS[0]:-}"
|
||
|
||
case "$CMD" in
|
||
version) printf '%s %s\n' "netease-music" "$(cat "$SKILL_DIR/VERSION" 2>/dev/null || printf unknown)" ;;
|
||
help) cmd_help "${ARGS[0]:-}" ;;
|
||
setup) cmd_setup "$@" ;;
|
||
login) cmd_login ;;
|
||
check) cmd_check ;;
|
||
daily) need_deps; LIMIT="${LIMIT:-40}"; cmd_daily ;;
|
||
play) need_deps; cmd_play "${ARGS[0]:-}" ;;
|
||
play-ids) need_deps; cmd_play_ids ;;
|
||
search) need_deps; LIMIT="${LIMIT:-20}"; cmd_search ;;
|
||
cover) need_deps; api cover ${ARGS[@]+"${ARGS[@]}"} ;;
|
||
liked) need_deps; cmd_liked ;;
|
||
liked-ids) need_deps; cmd_liked_ids ;;
|
||
artist) need_deps; cmd_artist "${ARGS[0]:-}" ;;
|
||
album) need_deps; cmd_album "${ARGS[0]:-}" ;;
|
||
albums) need_deps; cmd_albums "${ARGS[0]:-}" ;;
|
||
queue) need_deps; cmd_queue ;;
|
||
remove) need_deps; cmd_remove ;;
|
||
clear) need_deps; cmd_clear ;;
|
||
jump) need_deps
|
||
jn="$(jump_target "${ARGS[0]:-}")" || exit $?
|
||
mpvctl_or_die jump "$jn" >/dev/null; kv command jump; kv jumped "${ARGS[0]}"; emit_status ;;
|
||
myplaylists) need_deps; cmd_myplaylists "${ARGS[0]:-}" ;;
|
||
playlist) need_deps; [ -n "${ARGS[0]:-}" ] || die usage "playlist 需要歌单名/id/链接(例:netease-music playlist 夜跑)" 2
|
||
cmd_playlist "${ARGS[0]}" ;;
|
||
playlists) need_deps; cmd_playlists ;;
|
||
charts) need_deps; cmd_charts ;;
|
||
chart) need_deps; cmd_chart "${ARGS[0]:-}" ;;
|
||
myalbums) need_deps; cmd_myalbums ;;
|
||
myartists) need_deps; cmd_myartists ;;
|
||
fm) need_deps; cmd_fm ;;
|
||
recent) need_deps; cmd_recent ;;
|
||
replay) need_deps; cmd_replay "${ARGS[0]:-1}" ;;
|
||
podcasts) need_deps; cmd_podcasts ;;
|
||
podcast) need_deps; cmd_podcast "${ARGS[0]:-}" ;;
|
||
episode) need_deps; cmd_episode "${ARGS[0]:-}" ;;
|
||
queues) cmd_queues ;;
|
||
queue-save) cmd_queue_save "${ARGS[0]:-}" ;;
|
||
queue-load) need_deps; cmd_queue_load "${ARGS[0]:-}" ;;
|
||
back) need_deps; cmd_back ;;
|
||
add) need_deps; cmd_add "${ARGS[0]:-}" ;;
|
||
pause|resume|toggle|next|prev|stop|seek|volume|shuffle|repeat)
|
||
need_deps
|
||
if [ "$CMD" = repeat ] && [ "${#ARGS[@]}" -gt 1 ]; then die usage "用法:repeat off|list|single(on = list)" 2; fi
|
||
case "$CMD" in
|
||
seek|volume|shuffle|repeat) mpvctl_or_die "$CMD" "${ARGS[0]:-}" >/dev/null ;;
|
||
*) mpvctl_or_die "$CMD" >/dev/null ;;
|
||
esac
|
||
[ "$CMD" = stop ] && keepalive_stop
|
||
kv command "$CMD"
|
||
[ -n "${ARGS[0]:-}" ] && kv requested_arg "${ARGS[0]}"
|
||
emit_status ;; # 变更后回读真值:命令生效与否,看这里的状态
|
||
status) need_deps; cmd_status ;;
|
||
like|unlike) need_deps; [ "$CMD" = unlike ] && set -- --unlike || set --; cmd_like "$@" ;;
|
||
dislike) need_deps; cmd_dislike ;;
|
||
lyric) need_deps; cmd_lyric "${ARGS[0]:-}" ;;
|
||
*) die usage "未知命令:${CMD}(跑 netease-music help)" 2 ;;
|
||
esac
|