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 +98 -0
- ncm_dl-0.1.0/README.md +91 -0
- ncm_dl-0.1.0/pyproject.toml +14 -0
- ncm_dl-0.1.0/src/ncm_dl/__init__.py +1 -0
- ncm_dl-0.1.0/src/ncm_dl/cli.py +529 -0
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())
|