ncm-dl 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ncm_dl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.3
2
+ Name: ncm-dl
3
+ Version: 0.1.0
4
+ Summary: Small uv/Python CLI for downloading NetEase Cloud Music tracks.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # ncm-dl
9
+
10
+ `ncm-dl` 是一个基于 uv/Python 的网易云音乐命令行下载工具。
11
+
12
+ 运行时只用 Python 标准库,不依赖第三方包。配置优先读取环境变量,命令行参数可以覆盖环境变量。
13
+
14
+ ## 用法
15
+
16
+ ```bash
17
+ cd ~/ncm-dl
18
+
19
+ uv run ncm-dl search "晴天"
20
+ uv run ncm-dl download 186016 --output ~/音乐
21
+ uv run ncm-dl url 186016
22
+ ```
23
+
24
+ `download` 也可以写成 `dl`:
25
+
26
+ ```bash
27
+ uv run ncm-dl dl 186016
28
+ ```
29
+
30
+ 下载整个网易云歌单并生成 Navidrome 可导入的 M3U:
31
+
32
+ ```bash
33
+ export MUSIC_U='your_cookie_value'
34
+ uv run ncm-dl playlist 你的歌单ID \
35
+ --music-root /opt/navidrome/music \
36
+ --level lossless \
37
+ --navidrome-scan \
38
+ --navidrome-import \
39
+ --navidrome-sudo \
40
+ --sync
41
+ ```
42
+
43
+ 先不下载、只确认歌单是否能读取:
44
+
45
+ ```bash
46
+ uv run ncm-dl playlist 你的歌单ID --dry-run --limit 10
47
+ ```
48
+
49
+ 默认会把歌曲下载到:
50
+
51
+ ```text
52
+ /opt/navidrome/music/netease/<歌单名-歌单ID>/
53
+ ```
54
+
55
+ 并生成:
56
+
57
+ ```text
58
+ /opt/navidrome/music/playlists/<歌单名-歌单ID>.m3u
59
+ ```
60
+
61
+ M3U 里的歌曲路径会相对 `--music-root` 写入,适合 Navidrome 扫描和 `navidrome pls import`。
62
+
63
+ ## 环境变量
64
+
65
+ 支持这些环境变量:
66
+
67
+ | 变量 | 作用 |
68
+ | --- | --- |
69
+ | `MUSIC_U` | 网易云 `MUSIC_U` cookie,用于付费/VIP/无损歌曲。 |
70
+ | `OUTPUT_DIR` | 默认下载目录。 |
71
+ | `LEVEL` | 默认音质:`standard`、`higher`、`exhigh`、`lossless`、`hires`。默认 `exhigh`。 |
72
+ | `LIMIT` | 默认搜索结果数量。默认 `10`。 |
73
+ | `OVERWRITE` | 设为 `1`、`true` 或 `yes` 时,默认覆盖同名文件。 |
74
+ | `CHECK_MD5` | 设为 `0`、`false` 或 `no` 时,默认关闭 MD5 校验。 |
75
+ | `NAVIDROME_MUSIC_ROOT` | `playlist` 命令默认音乐库根目录。默认 `/opt/navidrome/music`。 |
76
+ | `NAVIDROME_BIN` | `playlist --navidrome-import` 使用的 Navidrome 可执行文件。默认 `navidrome`。 |
77
+ | `NAVIDROME_CONFIG` | Navidrome 配置文件。默认 `/etc/navidrome/navidrome.toml`。 |
78
+ | `NAVIDROME_USER` | 导入歌单所属的 Navidrome 用户。默认第一个管理员。 |
79
+ | `NAVIDROME_SUDO` | 设为 `1`、`true` 或 `yes` 时,导入步骤使用 `sudo navidrome`。 |
80
+ | `NAVIDROME_SYNC` | 设为 `1`、`true` 或 `yes` 时,导入时加 `--sync`。 |
81
+ | `NCM_TIMEOUT` | 下载超时时间,单位秒。默认 `60`。 |
82
+ | `NCM_USER_AGENT` | 自定义请求 User-Agent。 |
83
+
84
+ 示例:
85
+
86
+ ```bash
87
+ export MUSIC_U='your_cookie_value'
88
+ export OUTPUT_DIR="$HOME/音乐"
89
+ export LEVEL=lossless
90
+
91
+ uv run ncm-dl dl 186016
92
+ ```
93
+
94
+ ## 说明
95
+
96
+ - 本工具不会把 `MUSIC_U` 写入磁盘。
97
+ - 如果歌曲没有返回可播放 URL,通常需要设置有效网易云账号的 `MUSIC_U`。
98
+ - 网易云返回 MD5 时会默认校验下载文件;只有排查问题时才建议使用 `--no-md5`。
ncm_dl-0.1.0/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # ncm-dl
2
+
3
+ `ncm-dl` 是一个基于 uv/Python 的网易云音乐命令行下载工具。
4
+
5
+ 运行时只用 Python 标准库,不依赖第三方包。配置优先读取环境变量,命令行参数可以覆盖环境变量。
6
+
7
+ ## 用法
8
+
9
+ ```bash
10
+ cd ~/ncm-dl
11
+
12
+ uv run ncm-dl search "晴天"
13
+ uv run ncm-dl download 186016 --output ~/音乐
14
+ uv run ncm-dl url 186016
15
+ ```
16
+
17
+ `download` 也可以写成 `dl`:
18
+
19
+ ```bash
20
+ uv run ncm-dl dl 186016
21
+ ```
22
+
23
+ 下载整个网易云歌单并生成 Navidrome 可导入的 M3U:
24
+
25
+ ```bash
26
+ export MUSIC_U='your_cookie_value'
27
+ uv run ncm-dl playlist 你的歌单ID \
28
+ --music-root /opt/navidrome/music \
29
+ --level lossless \
30
+ --navidrome-scan \
31
+ --navidrome-import \
32
+ --navidrome-sudo \
33
+ --sync
34
+ ```
35
+
36
+ 先不下载、只确认歌单是否能读取:
37
+
38
+ ```bash
39
+ uv run ncm-dl playlist 你的歌单ID --dry-run --limit 10
40
+ ```
41
+
42
+ 默认会把歌曲下载到:
43
+
44
+ ```text
45
+ /opt/navidrome/music/netease/<歌单名-歌单ID>/
46
+ ```
47
+
48
+ 并生成:
49
+
50
+ ```text
51
+ /opt/navidrome/music/playlists/<歌单名-歌单ID>.m3u
52
+ ```
53
+
54
+ M3U 里的歌曲路径会相对 `--music-root` 写入,适合 Navidrome 扫描和 `navidrome pls import`。
55
+
56
+ ## 环境变量
57
+
58
+ 支持这些环境变量:
59
+
60
+ | 变量 | 作用 |
61
+ | --- | --- |
62
+ | `MUSIC_U` | 网易云 `MUSIC_U` cookie,用于付费/VIP/无损歌曲。 |
63
+ | `OUTPUT_DIR` | 默认下载目录。 |
64
+ | `LEVEL` | 默认音质:`standard`、`higher`、`exhigh`、`lossless`、`hires`。默认 `exhigh`。 |
65
+ | `LIMIT` | 默认搜索结果数量。默认 `10`。 |
66
+ | `OVERWRITE` | 设为 `1`、`true` 或 `yes` 时,默认覆盖同名文件。 |
67
+ | `CHECK_MD5` | 设为 `0`、`false` 或 `no` 时,默认关闭 MD5 校验。 |
68
+ | `NAVIDROME_MUSIC_ROOT` | `playlist` 命令默认音乐库根目录。默认 `/opt/navidrome/music`。 |
69
+ | `NAVIDROME_BIN` | `playlist --navidrome-import` 使用的 Navidrome 可执行文件。默认 `navidrome`。 |
70
+ | `NAVIDROME_CONFIG` | Navidrome 配置文件。默认 `/etc/navidrome/navidrome.toml`。 |
71
+ | `NAVIDROME_USER` | 导入歌单所属的 Navidrome 用户。默认第一个管理员。 |
72
+ | `NAVIDROME_SUDO` | 设为 `1`、`true` 或 `yes` 时,导入步骤使用 `sudo navidrome`。 |
73
+ | `NAVIDROME_SYNC` | 设为 `1`、`true` 或 `yes` 时,导入时加 `--sync`。 |
74
+ | `NCM_TIMEOUT` | 下载超时时间,单位秒。默认 `60`。 |
75
+ | `NCM_USER_AGENT` | 自定义请求 User-Agent。 |
76
+
77
+ 示例:
78
+
79
+ ```bash
80
+ export MUSIC_U='your_cookie_value'
81
+ export OUTPUT_DIR="$HOME/音乐"
82
+ export LEVEL=lossless
83
+
84
+ uv run ncm-dl dl 186016
85
+ ```
86
+
87
+ ## 说明
88
+
89
+ - 本工具不会把 `MUSIC_U` 写入磁盘。
90
+ - 如果歌曲没有返回可播放 URL,通常需要设置有效网易云账号的 `MUSIC_U`。
91
+ - 网易云返回 MD5 时会默认校验下载文件;只有排查问题时才建议使用 `--no-md5`。
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "ncm-dl"
3
+ version = "0.1.0"
4
+ description = "Small uv/Python CLI for downloading NetEase Cloud Music tracks."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = []
8
+
9
+ [project.scripts]
10
+ ncm-dl = "ncm_dl.cli:main"
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.8.0,<0.9.0"]
14
+ build-backend = "uv_build"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,529 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ API_BASE = "https://music.163.com"
20
+ DEFAULT_UA = (
21
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
22
+ "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
23
+ )
24
+ LEVEL_TO_BR = {
25
+ "standard": 128000,
26
+ "higher": 192000,
27
+ "exhigh": 320000,
28
+ "lossless": 999000,
29
+ "hires": 1999000,
30
+ }
31
+
32
+
33
+ class NCMError(RuntimeError):
34
+ pass
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Song:
39
+ id: int
40
+ name: str
41
+ artists: str
42
+ album: str
43
+ duration_ms: int
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SongURL:
48
+ url: str
49
+ size: int
50
+ md5: str
51
+ ext: str
52
+ level: str
53
+ br: int
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Playlist:
58
+ id: int
59
+ name: str
60
+ track_ids: list[int]
61
+
62
+
63
+ def env(name: str, default: str | None = None) -> str | None:
64
+ value = os.environ.get(name)
65
+ if value is None or value == "":
66
+ return default
67
+ return value
68
+
69
+
70
+ def request_json(
71
+ endpoint: str,
72
+ *,
73
+ query: dict[str, Any] | None = None,
74
+ form: dict[str, Any] | None = None,
75
+ music_u: str | None = None,
76
+ timeout: int = 20,
77
+ ) -> dict[str, Any]:
78
+ url = API_BASE + endpoint
79
+ if query:
80
+ url += "?" + urllib.parse.urlencode(query)
81
+
82
+ data = None
83
+ headers = {
84
+ "Accept": "application/json,text/plain,*/*",
85
+ "Referer": "https://music.163.com/",
86
+ "User-Agent": env("NCM_USER_AGENT", DEFAULT_UA) or DEFAULT_UA,
87
+ }
88
+ if music_u:
89
+ headers["Cookie"] = f"MUSIC_U={music_u}"
90
+ if form:
91
+ data = urllib.parse.urlencode(form).encode()
92
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
93
+
94
+ req = urllib.request.Request(url, data=data, headers=headers)
95
+ try:
96
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
97
+ raw = resp.read().decode("utf-8")
98
+ except urllib.error.HTTPError as exc:
99
+ raise NCMError(f"HTTP {exc.code}: {url}") from exc
100
+ except urllib.error.URLError as exc:
101
+ raise NCMError(f"request failed: {exc.reason}") from exc
102
+
103
+ try:
104
+ parsed = json.loads(raw)
105
+ except json.JSONDecodeError as exc:
106
+ raise NCMError(f"invalid JSON from {endpoint}: {raw[:120]}") from exc
107
+ if not isinstance(parsed, dict):
108
+ raise NCMError(f"unexpected response from {endpoint}")
109
+ return parsed
110
+
111
+
112
+ def parse_song(raw: dict[str, Any]) -> Song:
113
+ artists_raw = raw.get("artists") or raw.get("ar") or []
114
+ album_raw = raw.get("album") or raw.get("al") or {}
115
+ artists = ", ".join(a.get("name", "") for a in artists_raw if a.get("name"))
116
+ return Song(
117
+ id=int(raw["id"]),
118
+ name=str(raw.get("name") or "Unknown"),
119
+ artists=artists or "Unknown",
120
+ album=str(album_raw.get("name") or "Unknown"),
121
+ duration_ms=int(raw.get("duration") or raw.get("dt") or 0),
122
+ )
123
+
124
+
125
+ def song_detail(song_id: int, music_u: str | None) -> Song:
126
+ data = request_json(
127
+ "/api/song/detail/",
128
+ query={"ids": json.dumps([song_id], separators=(",", ":"))},
129
+ music_u=music_u,
130
+ )
131
+ songs = data.get("songs") or []
132
+ if not songs:
133
+ raise NCMError(f"song not found: {song_id}")
134
+ return parse_song(songs[0])
135
+
136
+
137
+ def search_songs(keyword: str, limit: int, music_u: str | None) -> list[Song]:
138
+ data = request_json(
139
+ "/api/search/get/web",
140
+ form={"s": keyword, "type": 1, "limit": limit, "offset": 0, "total": "true"},
141
+ music_u=music_u,
142
+ )
143
+ songs = ((data.get("result") or {}).get("songs")) or []
144
+ return [parse_song(song) for song in songs]
145
+
146
+
147
+ def playlist_detail(playlist_id: int, music_u: str | None) -> Playlist:
148
+ data = request_json(
149
+ "/api/v6/playlist/detail",
150
+ query={"id": playlist_id, "n": 100000, "s": 8},
151
+ music_u=music_u,
152
+ )
153
+ playlist = data.get("playlist") or {}
154
+ if not playlist:
155
+ raise NCMError(f"playlist not found: {playlist_id}")
156
+
157
+ track_ids = []
158
+ for item in playlist.get("trackIds") or []:
159
+ try:
160
+ track_ids.append(int(item["id"]))
161
+ except (KeyError, TypeError, ValueError):
162
+ continue
163
+
164
+ if not track_ids:
165
+ for item in playlist.get("tracks") or []:
166
+ try:
167
+ track_ids.append(int(item["id"]))
168
+ except (KeyError, TypeError, ValueError):
169
+ continue
170
+
171
+ if not track_ids:
172
+ raise NCMError(f"playlist has no tracks: {playlist_id}")
173
+
174
+ return Playlist(
175
+ id=int(playlist.get("id") or playlist_id),
176
+ name=str(playlist.get("name") or f"playlist-{playlist_id}"),
177
+ track_ids=track_ids,
178
+ )
179
+
180
+
181
+ def song_url(song_id: int, level: str, music_u: str | None) -> SongURL:
182
+ level = level.lower()
183
+ if level not in LEVEL_TO_BR:
184
+ raise NCMError(f"unknown level: {level}")
185
+
186
+ data = request_json(
187
+ "/api/song/enhance/player/url/v1",
188
+ query={
189
+ "ids": json.dumps([song_id], separators=(",", ":")),
190
+ "level": level,
191
+ "encodeType": "flac" if level in {"lossless", "hires"} else "mp3",
192
+ },
193
+ music_u=music_u,
194
+ )
195
+ items = data.get("data") or []
196
+
197
+ if not items or not items[0].get("url"):
198
+ data = request_json(
199
+ "/api/song/enhance/player/url",
200
+ query={"ids": json.dumps([song_id], separators=(",", ":")), "br": LEVEL_TO_BR[level]},
201
+ music_u=music_u,
202
+ )
203
+ items = data.get("data") or []
204
+
205
+ if not items or not items[0].get("url"):
206
+ raise NCMError("没有拿到可播放 URL;付费/VIP 歌曲请设置 MUSIC_U")
207
+
208
+ item = items[0]
209
+ ext = str(item.get("type") or "").lower()
210
+ if ext not in {"mp3", "flac"}:
211
+ path = urllib.parse.urlparse(str(item["url"])).path
212
+ suffix = Path(path).suffix.lstrip(".").lower()
213
+ ext = suffix if suffix in {"mp3", "flac"} else "mp3"
214
+ return SongURL(
215
+ url=str(item["url"]),
216
+ size=int(item.get("size") or 0),
217
+ md5=str(item.get("md5") or ""),
218
+ ext=ext,
219
+ level=str(item.get("level") or level),
220
+ br=int(item.get("br") or 0),
221
+ )
222
+
223
+
224
+ def clean_filename(value: str) -> str:
225
+ value = re.sub(r'[\\/:*?"<>|]+', " ", value)
226
+ value = re.sub(r"\s+", " ", value).strip(" .")
227
+ return value or "Unknown"
228
+
229
+
230
+ def output_path(song: Song, info: SongURL, output_dir: Path) -> Path:
231
+ return output_dir / f"{clean_filename(song.artists)} - {clean_filename(song.name)}.{info.ext}"
232
+
233
+
234
+ def playlist_output_path(song: Song, info: SongURL, output_dir: Path) -> Path:
235
+ return output_dir / f"{clean_filename(song.artists)} - {clean_filename(song.name)} [{song.id}].{info.ext}"
236
+
237
+
238
+ def format_size(size: int) -> str:
239
+ if size <= 0:
240
+ return "unknown"
241
+ units = ["B", "KiB", "MiB", "GiB"]
242
+ value = float(size)
243
+ unit = units[0]
244
+ for unit in units:
245
+ if value < 1024 or unit == units[-1]:
246
+ break
247
+ value /= 1024
248
+ return f"{value:.1f} {unit}"
249
+
250
+
251
+ def download_file(url: str, dest: Path, expected_md5: str, overwrite: bool) -> None:
252
+ if dest.exists() and not overwrite:
253
+ raise NCMError(f"文件已存在: {dest},可加 --overwrite 覆盖")
254
+
255
+ dest.parent.mkdir(parents=True, exist_ok=True)
256
+ tmp = dest.with_suffix(dest.suffix + ".part")
257
+ headers = {"User-Agent": env("NCM_USER_AGENT", DEFAULT_UA) or DEFAULT_UA}
258
+ req = urllib.request.Request(url.replace("http://", "https://", 1), headers=headers)
259
+
260
+ try:
261
+ with urllib.request.urlopen(req, timeout=int(env("NCM_TIMEOUT", "60") or "60")) as resp:
262
+ total = int(resp.headers.get("Content-Length") or 0)
263
+ md5 = hashlib.md5()
264
+ written = 0
265
+ last_print = 0.0
266
+ with tmp.open("wb") as f:
267
+ while True:
268
+ chunk = resp.read(1024 * 256)
269
+ if not chunk:
270
+ break
271
+ f.write(chunk)
272
+ md5.update(chunk)
273
+ written += len(chunk)
274
+ now = time.monotonic()
275
+ if now - last_print >= 0.5:
276
+ print_progress(written, total)
277
+ last_print = now
278
+ except Exception:
279
+ if tmp.exists():
280
+ tmp.unlink()
281
+ raise
282
+
283
+ print_progress(tmp.stat().st_size, total)
284
+ print()
285
+
286
+ if expected_md5 and md5.hexdigest().lower() != expected_md5.lower():
287
+ tmp.unlink(missing_ok=True)
288
+ raise NCMError("MD5 校验失败")
289
+
290
+ tmp.replace(dest)
291
+
292
+
293
+ def print_progress(written: int, total: int) -> None:
294
+ if total > 0:
295
+ percent = min(100, written * 100 // total)
296
+ bar_width = 24
297
+ filled = min(bar_width, written * bar_width // total)
298
+ bar = "#" * filled + "-" * (bar_width - filled)
299
+ msg = f"\r[{bar}] {percent:3d}% {format_size(written)}/{format_size(total)}"
300
+ else:
301
+ msg = f"\rdownloaded {format_size(written)}"
302
+ print(msg, end="", flush=True)
303
+
304
+
305
+ def add_common(parser: argparse.ArgumentParser) -> None:
306
+ parser.add_argument("--music-u", default=env("MUSIC_U"), help="网易云 MUSIC_U cookie;默认读取环境变量 MUSIC_U")
307
+ parser.add_argument("--level", default=env("LEVEL", "exhigh"), choices=sorted(LEVEL_TO_BR), help="音质等级;默认读取环境变量 LEVEL,未设置则 exhigh")
308
+
309
+
310
+ def bool_env(name: str, default: bool = False) -> bool:
311
+ value = env(name)
312
+ if value is None:
313
+ return default
314
+ return value.lower() in {"1", "true", "yes", "on"}
315
+
316
+
317
+ def cmd_search(args: argparse.Namespace) -> int:
318
+ songs = search_songs(args.keyword, args.limit, args.music_u)
319
+ if not songs:
320
+ print("没有找到歌曲")
321
+ return 1
322
+ for idx, song in enumerate(songs, 1):
323
+ duration = f"{song.duration_ms // 60000}:{song.duration_ms // 1000 % 60:02d}" if song.duration_ms else "?:??"
324
+ print(f"{idx:2d}. {song.id} {song.name} - {song.artists} [{song.album}] {duration}")
325
+ return 0
326
+
327
+
328
+ def cmd_url(args: argparse.Namespace) -> int:
329
+ info = song_url(args.song_id, args.level, args.music_u)
330
+ print(info.url)
331
+ return 0
332
+
333
+
334
+ def cmd_download(args: argparse.Namespace) -> int:
335
+ out = Path(args.output or env("OUTPUT_DIR", ".") or ".").expanduser()
336
+ song = song_detail(args.song_id, args.music_u)
337
+ info = song_url(args.song_id, args.level, args.music_u)
338
+ dest = Path(args.file).expanduser() if args.file else output_path(song, info, out)
339
+
340
+ print(f"歌曲: {song.name} - {song.artists}")
341
+ print(f"专辑: {song.album}")
342
+ print(f"音质: {info.level} {info.br // 1000 if info.br else '?'} kbps, {format_size(info.size)}")
343
+ print(f"输出: {dest}")
344
+ download_file(info.url, dest, info.md5 if args.check_md5 else "", args.overwrite)
345
+ print(f"完成: {dest}")
346
+ return 0
347
+
348
+
349
+ def m3u_entry_path(path: Path, music_root: Path) -> str:
350
+ resolved = path.resolve()
351
+ try:
352
+ return resolved.relative_to(music_root.resolve()).as_posix()
353
+ except ValueError:
354
+ return str(resolved)
355
+
356
+
357
+ def write_m3u(path: Path, playlist_name: str, tracks: list[tuple[Song, Path]], music_root: Path) -> None:
358
+ path.parent.mkdir(parents=True, exist_ok=True)
359
+ with path.open("w", encoding="utf-8", newline="\n") as f:
360
+ f.write("#EXTM3U\n")
361
+ f.write(f"#PLAYLIST:{playlist_name}\n")
362
+ for song, track_path in tracks:
363
+ duration = song.duration_ms // 1000 if song.duration_ms else 0
364
+ f.write(f"#EXTINF:{duration},{song.artists} - {song.name}\n")
365
+ f.write(m3u_entry_path(track_path, music_root) + "\n")
366
+
367
+
368
+ def navidrome_cmd(args: argparse.Namespace, *parts: str) -> list[str]:
369
+ cmd = [
370
+ args.navidrome_bin,
371
+ "--configfile",
372
+ args.navidrome_config,
373
+ ]
374
+ cmd.extend(parts)
375
+ if args.navidrome_sudo:
376
+ cmd.insert(0, "sudo")
377
+ return cmd
378
+
379
+
380
+ def scan_navidrome(args: argparse.Namespace) -> int:
381
+ cmd = navidrome_cmd(args, "scan")
382
+ if args.navidrome_full_scan:
383
+ cmd.append("--full")
384
+ print("Navidrome 扫描:", " ".join(cmd))
385
+ return subprocess.run(cmd, check=False).returncode
386
+
387
+
388
+ def import_to_navidrome(args: argparse.Namespace, m3u_path: Path) -> int:
389
+ cmd = navidrome_cmd(args, "pls", "import")
390
+ if args.navidrome_user:
391
+ cmd.extend(["--user", args.navidrome_user])
392
+ if args.sync:
393
+ cmd.append("--sync")
394
+ cmd.append(str(m3u_path))
395
+ print("Navidrome 导入:", " ".join(cmd))
396
+ return subprocess.run(cmd, check=False).returncode
397
+
398
+
399
+ def cmd_playlist(args: argparse.Namespace) -> int:
400
+ playlist = playlist_detail(args.playlist_id, args.music_u)
401
+ track_ids = playlist.track_ids[: args.limit] if args.limit else playlist.track_ids
402
+
403
+ music_root = Path(args.music_root).expanduser()
404
+ default_dir = music_root / "netease" / f"{clean_filename(playlist.name)}-{playlist.id}"
405
+ output_dir = Path(args.output).expanduser() if args.output else default_dir
406
+ default_m3u = music_root / "playlists" / f"{clean_filename(playlist.name)}-{playlist.id}.m3u"
407
+ m3u_path = Path(args.m3u).expanduser() if args.m3u else default_m3u
408
+
409
+ print(f"歌单: {playlist.name} ({playlist.id})")
410
+ print(f"曲目: {len(track_ids)}/{len(playlist.track_ids)}")
411
+ print(f"输出目录: {output_dir}")
412
+ print(f"M3U: {m3u_path}")
413
+ if args.dry_run:
414
+ print("dry-run: 只读取歌单,不下载、不写 m3u、不导入 Navidrome")
415
+ for idx, song_id in enumerate(track_ids[:20], 1):
416
+ print(f"{idx:3d}. {song_id}")
417
+ if len(track_ids) > 20:
418
+ print(f"... 还有 {len(track_ids) - 20} 首")
419
+ return 0
420
+
421
+ output_dir.mkdir(parents=True, exist_ok=True)
422
+ downloaded: list[tuple[Song, Path]] = []
423
+ failures: list[tuple[int, str]] = []
424
+
425
+ for idx, song_id in enumerate(track_ids, 1):
426
+ print(f"\n[{idx}/{len(track_ids)}] {song_id}")
427
+ try:
428
+ song = song_detail(song_id, args.music_u)
429
+ info = song_url(song_id, args.level, args.music_u)
430
+ dest = playlist_output_path(song, info, output_dir)
431
+ print(f"歌曲: {song.name} - {song.artists}")
432
+ print(f"专辑: {song.album}")
433
+ print(f"音质: {info.level} {info.br // 1000 if info.br else '?'} kbps, {format_size(info.size)}")
434
+ print(f"输出: {dest}")
435
+ if dest.exists() and not args.overwrite:
436
+ print("已存在,跳过下载")
437
+ else:
438
+ download_file(info.url, dest, info.md5 if args.check_md5 else "", args.overwrite)
439
+ if dest.exists():
440
+ downloaded.append((song, dest))
441
+ except NCMError as exc:
442
+ failures.append((song_id, str(exc)))
443
+ print(f"失败: {song_id}: {exc}", file=sys.stderr)
444
+ if args.strict:
445
+ break
446
+
447
+ write_m3u(m3u_path, playlist.name, downloaded, music_root)
448
+ print(f"\n已写入 M3U: {m3u_path}")
449
+ print(f"成功匹配文件: {len(downloaded)}/{len(track_ids)}")
450
+ if failures:
451
+ print(f"失败: {len(failures)}")
452
+ for song_id, reason in failures[:20]:
453
+ print(f"- {song_id}: {reason}", file=sys.stderr)
454
+
455
+ if args.navidrome_scan:
456
+ rc = scan_navidrome(args)
457
+ if rc != 0:
458
+ return rc
459
+
460
+ if args.navidrome_import:
461
+ rc = import_to_navidrome(args, m3u_path)
462
+ if rc != 0:
463
+ return rc
464
+
465
+ return 1 if failures and args.strict else 0
466
+
467
+
468
+ def build_parser() -> argparse.ArgumentParser:
469
+ parser = argparse.ArgumentParser(prog="ncm-dl", description="基于 uv/Python 的网易云音乐下载 CLI。")
470
+ sub = parser.add_subparsers(dest="command", required=True)
471
+
472
+ p = sub.add_parser("search", help="搜索歌曲")
473
+ add_common(p)
474
+ p.add_argument("keyword")
475
+ p.add_argument("--limit", type=int, default=int(env("LIMIT", "10") or "10"))
476
+ p.set_defaults(func=cmd_search)
477
+
478
+ p = sub.add_parser("url", help="输出歌曲真实播放 URL")
479
+ add_common(p)
480
+ p.add_argument("song_id", type=int)
481
+ p.set_defaults(func=cmd_url)
482
+
483
+ p = sub.add_parser("download", aliases=["dl"], help="按歌曲 ID 下载")
484
+ add_common(p)
485
+ p.add_argument("song_id", type=int)
486
+ p.add_argument("-o", "--output", default=env("OUTPUT_DIR"), help="输出目录;默认读取环境变量 OUTPUT_DIR,未设置则当前目录")
487
+ p.add_argument("-f", "--file", help="指定完整输出文件路径")
488
+ p.add_argument("--overwrite", action="store_true", default=env("OVERWRITE", "").lower() in {"1", "true", "yes"}, help="覆盖已存在的同名文件;也可用环境变量 OVERWRITE=true")
489
+ p.add_argument("--no-md5", dest="check_md5", action="store_false", default=env("CHECK_MD5", "true").lower() not in {"0", "false", "no"}, help="关闭 MD5 校验;也可用环境变量 CHECK_MD5=false")
490
+ p.set_defaults(func=cmd_download)
491
+
492
+ p = sub.add_parser("playlist", aliases=["pl"], help="按网易云歌单 ID 下载并生成 M3U")
493
+ add_common(p)
494
+ p.add_argument("playlist_id", type=int)
495
+ p.add_argument("-o", "--output", default=env("OUTPUT_DIR"), help="歌曲输出目录;默认 <music-root>/netease/<歌单名-id>")
496
+ p.add_argument("--music-root", default=env("NAVIDROME_MUSIC_ROOT", "/opt/navidrome/music"), help="Navidrome MusicFolder;默认 /opt/navidrome/music")
497
+ p.add_argument("--m3u", default=env("M3U_FILE"), help="M3U 输出文件;默认 <music-root>/playlists/<歌单名-id>.m3u")
498
+ p.add_argument("--limit", type=int, help="只处理前 N 首,便于测试")
499
+ p.add_argument("--overwrite", action="store_true", default=bool_env("OVERWRITE"), help="覆盖已存在的同名文件;也可用环境变量 OVERWRITE=true")
500
+ p.add_argument("--no-md5", dest="check_md5", action="store_false", default=not bool_env("CHECK_MD5_DISABLED") and env("CHECK_MD5", "true").lower() not in {"0", "false", "no"}, help="关闭 MD5 校验;也可用环境变量 CHECK_MD5=false")
501
+ p.add_argument("--dry-run", action="store_true", help="只读取歌单和打印计划,不下载、不写 m3u")
502
+ p.add_argument("--strict", action="store_true", help="遇到第一首失败就停止,并返回非 0")
503
+ p.add_argument("--navidrome-scan", action="store_true", help="下载后先调用 navidrome scan,让新文件进入媒体库")
504
+ p.add_argument("--navidrome-full-scan", action="store_true", help="配合 --navidrome-scan 使用,执行 navidrome scan --full")
505
+ p.add_argument("--navidrome-import", action="store_true", help="下载后调用 navidrome pls import 导入 M3U")
506
+ p.add_argument("--navidrome-bin", default=env("NAVIDROME_BIN", "navidrome"), help="navidrome 可执行文件路径")
507
+ p.add_argument("--navidrome-config", default=env("NAVIDROME_CONFIG", "/etc/navidrome/navidrome.toml"), help="Navidrome 配置文件")
508
+ p.add_argument("--navidrome-user", default=env("NAVIDROME_USER"), help="导入为指定 Navidrome 用户,默认第一个管理员")
509
+ p.add_argument("--navidrome-sudo", action="store_true", default=bool_env("NAVIDROME_SUDO"), help="导入步骤使用 sudo navidrome;也可用环境变量 NAVIDROME_SUDO=true")
510
+ p.add_argument("--sync", action="store_true", default=bool_env("NAVIDROME_SYNC"), help="导入时加 navidrome pls import --sync")
511
+ p.set_defaults(func=cmd_playlist)
512
+ return parser
513
+
514
+
515
+ def main(argv: list[str] | None = None) -> int:
516
+ parser = build_parser()
517
+ args = parser.parse_args(argv)
518
+ try:
519
+ return int(args.func(args) or 0)
520
+ except KeyboardInterrupt:
521
+ print("\n已中止", file=sys.stderr)
522
+ return 130
523
+ except NCMError as exc:
524
+ print(f"错误: {exc}", file=sys.stderr)
525
+ return 1
526
+
527
+
528
+ if __name__ == "__main__":
529
+ raise SystemExit(main())