├── .gitignore
├── README.md
├── main.py
└── requirements.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | config.json
2 | fail.log
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
NCM-DOWNLOADER
3 |

4 |
5 |
6 | ## 这是什么?
7 | 这是一个基于[NeteaseCloudMusicApi](https://gitlab.com/Binaryify/neteasecloudmusicapi)的网易云音乐歌曲、歌单、专辑下载工具,支持批量下载,支持对下载的音乐注入元数据(即文件属性包含歌曲名、专辑名、歌手名等信息)。
8 |
9 | > [!WARNING]
10 | > 本项目仍在开发中,目前可能不稳定,请酌情使用!
11 |
12 | ## 概念
13 | 为了理解程序使用中出现的特有名词,你需要了解以下概念。
14 | - NeteaseCloudMusicApi(NCMAPI)
15 | 这是一个网易云音乐 Node.js API。简单来说,它可以充当你与网易云的“中间商”,使开发者能够更加简单的通过API使用网易云的功能而无需考虑伪造请求头等繁琐步骤。
16 | 你可以自行部署API,也可以寻找网上的公开API,但注意可能会泄露账号密码及隐私。
17 |
18 | - Cookie
19 | 详见[Cookie - Wikipedia](https://www.wikiwand.com/zh-cn/articles/Cookie)。
20 |
21 | - 歌曲/歌单/专辑/歌手ID
22 | 在网易云中,每个歌曲/歌单/专辑/歌手都有专属于自己的一个纯数字ID。你可以在网易云网页端及客户端分享链接中找到`id`参数,例如`https://music.163.com/#/playlist?id=2805215308`中这个歌单的ID就为`2805215308`。
23 |
24 | ## 使用
25 | 可以直接在[Releases](https://github.com/rong6/ncm-downloader/releases)下载构建版本。
26 |
27 | 或克隆源码使用。
28 |
29 | ``` bash
30 | git clone https://github.com/rong6/ncm-downloader.git
31 | cd ncm-downloader
32 | pip install -r requirements.txt
33 | ```
34 |
35 | 然后,你需要部署[NeteaseCloudMusicApi](https://gitlab.com/Binaryify/neteasecloudmusicapi),可在本地也可在云端。
36 |
37 | 打开`https:///qrlogin.html`,按下`F12`打开开发者工具,切换至`网络`选项卡,扫码登录网易云。
38 | 找到`/check?key=xxx`的相关请求,切换至`预览`选项卡,查看`message`值为`授权登陆成功`或`code`值为`803`的那个请求,右键复制上面`cookie`的值。
39 | 
40 |
41 | 运行:
42 | ``` bash
43 | python main.py
44 | ```
45 | 输入你NeteaseCloudMusicApi的网址(带协议头,结尾不带`/`),再粘贴你上面获取到的Cookie,接下来按提示操作即可。**注意,若你的账号没有会员则无法下载会员歌曲,也无法下载会员音质,无法越权使用。**
46 |
47 | ## 预览图
48 | 
49 |
50 | ## 免责声明
51 | 本程序仅供个人学习和交流使用,请勿将其用于任何商业目的或非法用途。使用本程序下载的内容应当符合相关法律法规和平台服务协议的要求。开发者不对因使用本程序而产生的任何直接或间接的法律责任负责。
52 |
53 | 请尊重版权,支持正版音乐。如果您喜欢某些音乐作品,请购买正版或通过合法渠道获取。任何因未经授权下载、传播或使用受版权保护的内容而引发的后果,均由使用者自行承担。
54 |
55 | 开发者对本程序的使用效果不作任何保证,不对因使用或无法使用本程序而导致的任何损失负责。使用本程序的风险由使用者自行承担。
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | import requests
4 | import traceback
5 | from datetime import datetime
6 | from tqdm import tqdm
7 | from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, USLT
8 | from mutagen.mp3 import MP3
9 | from mutagen.flac import FLAC, Picture
10 | from concurrent.futures import ThreadPoolExecutor, as_completed
11 |
12 | RED = "\033[31m"
13 | YELLOW = "\033[33m"
14 | GREEN = "\033[32m"
15 | RESET = "\033[0m"
16 | lines = [
17 | " _ _ ____ __ __ ____ _____ ___ _ _ ___ _ ____ _____ ____ ",
18 | " | \\ | |/ ___| \\/ | | _ \\ / _ \\ \\ / / \\ | | | / _ \\ / \\ | _ \\| ____| _ \\ ",
19 | " | \\| | | | |\\/| | | | | | | | \\ \\ /\\ / /| \\| | | | | | |/ _ \\ | | | | _| | |_) |",
20 | " | |\\ | |___| | | | | |_| | |_| |\\ V V / | |\\ | |__| |_| / ___ \\| |_| | |___| _ < ",
21 | " |_| \\_|\\____|_| |_| |____/ \\___/ \\_/\\_/ |_| \\_|_____\\___/_/ \\_\\____/|_____|_| \\_\\"
22 | ]
23 |
24 | for line in lines:
25 | print(f"{GREEN}{line}{RESET}")
26 |
27 | print("欢迎使用NCM Downloader!")
28 | print("GitHub:https://github.com/rong6/ncm-downloader")
29 |
30 | # 加载配置
31 | def load_config():
32 | config_file = 'config.json'
33 | if os.path.exists(config_file):
34 | with open(config_file, 'r', encoding='utf-8') as f:
35 | return json.load(f)
36 | return {}
37 |
38 | def save_config(config):
39 | with open('config.json', 'w', encoding='utf-8') as f:
40 | json.dump(config, f, ensure_ascii=False, indent=4)
41 |
42 | def get_input(prompt, key, config):
43 | if key not in config or not config[key]:
44 | value = input(prompt)
45 | config[key] = value
46 | return config[key]
47 |
48 | config = load_config()
49 | ncmapi = get_input("请输入NCMAPI URL:", 'ncmapi', config)
50 | cookie = get_input("请输入cookie:", 'cookie', config)
51 | save_config(config)
52 |
53 | headers = {"cookie": cookie}
54 |
55 | # 选择下载类型
56 | def choose_download_type():
57 | while True:
58 | print(f"下载类型:{GREEN}[1]{RESET}歌曲 {GREEN}[2]{RESET}歌单 {GREEN}[3]{RESET}专辑 {GREEN}[4]{RESET}歌手所有歌曲")
59 | choice = input("请选择下载类型:")
60 | if choice == '1':
61 | return 'song', input("请输入歌曲ID:")
62 | elif choice == '2':
63 | return 'playlist', input("请输入歌单ID:")
64 | elif choice == '3':
65 | return 'album', input("请输入专辑ID:")
66 | elif choice == '4':
67 | return 'artist', input("请输入歌手ID:")
68 | else:
69 | print("输入错误,请重新输入。")
70 |
71 | # 选择音质
72 | def choose_quality():
73 | qualities = {
74 | '1': f'{GREEN}[1]{RESET} standard => 标准',
75 | '2': f'{GREEN}[2]{RESET} higher => 较高',
76 | '3': f'{GREEN}[3]{RESET} exhigh => 极高',
77 | '4': f'{GREEN}[4]{RESET} lossless => 无损',
78 | '5': f'{GREEN}[5]{RESET} hires => Hi-Res',
79 | '6': f'{GREEN}[6]{RESET} jyeffect => 高清环绕声',
80 | '7': f'{GREEN}[7]{RESET} sky => 沉浸环绕声',
81 | '8': f'{GREEN}[8]{RESET} jymaster => 超清母带'
82 | }
83 | quality_levels = {
84 | '1': 'standard',
85 | '2': 'higher',
86 | '3': 'exhigh',
87 | '4': 'lossless',
88 | '5': 'hires',
89 | '6': 'jyeffect',
90 | '7': 'sky',
91 | '8': 'jymaster'
92 | }
93 | for k, v in qualities.items():
94 | print(v)
95 | quality_choice = input("输入音质对应数字:")
96 | return quality_levels.get(quality_choice, 'standard')
97 |
98 | # 选择歌词处理方式
99 | def choose_lyric_option():
100 | print(f"歌词处理方式:{GREEN}[1]{RESET}下载歌词文件 {GREEN}[2]{RESET}不下载歌词文件")
101 | return input("请选择歌词处理方式:")
102 |
103 | # 选择并发下载数量
104 | def choose_concurrent_downloads():
105 | while True:
106 | try:
107 | num = int(input("请输入同时并发下载歌曲数(1-50):"))
108 | if 1 <= num <= 50:
109 | return num
110 | else:
111 | print("请输入1到50之间的数字。")
112 | except ValueError:
113 | print(f"{RED}Error{RESET}: 请输入有效的数字。")
114 |
115 | # 注入元数据
116 | def inject_metadata(audio_path, song_info, lyrics, cover_data):
117 | ext = os.path.splitext(audio_path)[-1].lower()
118 | if ext == '.mp3':
119 | audio = MP3(audio_path, ID3=ID3)
120 | if audio.tags is None:
121 | audio.add_tags()
122 | audio.tags.add(TIT2(encoding=3, text=song_info['name']))
123 | audio.tags.add(TPE1(encoding=3, text=song_info['ar'][0]['name']))
124 | audio.tags.add(TALB(encoding=3, text=song_info['al']['name']))
125 | audio.tags.add(USLT(encoding=3, lang='eng', desc='', text=lyrics))
126 | audio.tags.add(APIC(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=cover_data))
127 | audio.save()
128 | elif ext == '.flac':
129 | audio = FLAC(audio_path)
130 | audio['title'] = song_info['name']
131 | audio['artist'] = song_info['ar'][0]['name']
132 | audio['album'] = song_info['al']['name']
133 | audio['lyrics'] = lyrics
134 | picture = Picture()
135 | picture.data = cover_data
136 | picture.type = 3
137 | picture.mime = 'image/jpeg'
138 | audio.add_picture(picture)
139 | audio.save()
140 |
141 | # 处理歌词数据
142 | def process_lyrics(lyrics):
143 | lines = lyrics.split('\n')
144 | processed_lyrics = []
145 | for line in lines:
146 | if line.startswith('{"t":'):
147 | json_line = json.loads(line)
148 | timestamp = json_line['t']
149 | text = ''.join([c['tx'] for c in json_line['c']])
150 | minutes = timestamp // 60000
151 | seconds = (timestamp % 60000) // 1000
152 | milliseconds = timestamp % 1000
153 | processed_lyrics.append(f"[{minutes:02}:{seconds:02}.{milliseconds:03}]{text}")
154 | else:
155 | processed_lyrics.append(line)
156 | return '\n'.join(processed_lyrics)
157 |
158 | def log_error(error_type, message, details=None, song_info=None):
159 | """统一的错误日志记录"""
160 | timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
161 | error_msg = f"[{timestamp}] {error_type}: {message}\n"
162 | if song_info:
163 | error_msg += f"歌曲信息: ID={song_info.get('id', 'Unknown')}, "
164 | error_msg += f"名称={song_info.get('name', 'Unknown')}, "
165 | error_msg += f"艺术家={song_info.get('ar', [{'name': 'Unknown'}])[0]['name']}\n"
166 | if details:
167 | error_msg += f"详细信息: {details}\n"
168 | error_msg += f"堆栈跟踪:\n{traceback.format_exc()}\n"
169 | error_msg += "-" * 80 + "\n"
170 |
171 | with open('fail.log', 'a', encoding='utf-8') as f:
172 | f.write(error_msg)
173 |
174 | def check_api_response(response, error_msg="API请求失败"):
175 | """检查API响应"""
176 | try:
177 | response.raise_for_status()
178 | data = response.json()
179 | if not data:
180 | raise ValueError("API返回空数据")
181 | return data
182 | except (requests.exceptions.RequestException, json.JSONDecodeError) as e:
183 | error_details = {
184 | 'url': response.url,
185 | 'status_code': response.status_code,
186 | 'response_text': response.text[:500] # 只记录前500个字符
187 | }
188 | log_error("API错误", error_msg, error_details)
189 | raise
190 |
191 | def safe_get_json(url, headers=None, timeout=10, error_msg=None):
192 | """安全的JSON请求"""
193 | try:
194 | response = requests.get(url, headers=headers, timeout=timeout)
195 | return check_api_response(response, error_msg)
196 | except Exception as e:
197 | log_error("网络请求错误", str(e))
198 | raise
199 |
200 | # 下载单首歌曲
201 | def download_song(song_id, quality, folder_name, lyric_option):
202 | retries = 3
203 | song_info = None
204 |
205 | for attempt in range(retries):
206 | try:
207 | # 获取歌曲详情
208 | song_detail_url = f"{ncmapi}/song/detail?ids={song_id}"
209 | song_detail_res = safe_get_json(
210 | song_detail_url,
211 | headers=headers,
212 | error_msg=f"获取歌曲详情失败 (ID: {song_id})"
213 | )
214 |
215 | if 'songs' not in song_detail_res or not song_detail_res['songs']:
216 | raise KeyError("API响应中缺少'songs'字段或为空")
217 |
218 | song_info = song_detail_res['songs'][0]
219 | song_name = song_info['name']
220 | artist_name = song_info['ar'][0]['name']
221 | album_name = song_info['al']['name']
222 | album_pic_url = song_info['al']['picUrl']
223 |
224 | # 获取下载链接
225 | download_url = f"{ncmapi}/song/url/v1?id={song_id}&level={quality}"
226 | download_res = safe_get_json(
227 | download_url,
228 | headers=headers,
229 | error_msg=f"获取下载链接失败 (ID: {song_id})"
230 | )
231 | if 'data' not in download_res or not download_res['data']:
232 | raise KeyError("API响应中缺少'data'字段或数据为空")
233 | song_data = download_res['data'][0]
234 | song_url = song_data['url']
235 | type = song_data.get('type', 'mp3')
236 |
237 | if not song_url:
238 | print(f"{RED}Error{RESET}: 无法获取歌曲 {song_name} 的下载链接,可能是版权限制。")
239 | with open('fail.log', 'a') as f:
240 | f.write(f"{song_id} - {song_name}: 无法获取下载链接,可能是版权限制。\n")
241 | return False
242 |
243 | # 获取歌词
244 | lyric_url = f"{ncmapi}/lyric/new?id={song_id}"
245 | lyric_res = requests.get(lyric_url, headers=headers, timeout=10).json()
246 | raw_lyrics = lyric_res.get('lrc', {}).get('lyric', '')
247 | lyrics = process_lyrics(raw_lyrics)
248 |
249 | # 设置文件扩展名
250 | ext = f".{type.lower()}"
251 | if ext not in ['.mp3', '.flac']:
252 | ext = '.mp3' # 默认使用mp3
253 |
254 | # 下载歌曲
255 | song_filename = os.path.join(folder_name, f"{album_name} - {song_name} - {artist_name}{ext}")
256 | print(f"{YELLOW}正在下载{RESET}:{song_filename}")
257 |
258 | # 使用更小的块大小和错误处理
259 | try:
260 | with requests.get(song_url, headers=headers, stream=True) as r:
261 | r.raise_for_status()
262 | total_size = int(r.headers.get('content-length', 0))
263 | if total_size == 0:
264 | raise ValueError("文件大小为0")
265 | with open(song_filename, 'wb') as f, tqdm(
266 | desc=f"{song_name} - {artist_name}",
267 | total=total_size,
268 | unit='MB',
269 | unit_scale=True,
270 | unit_divisor=1024 * 1024,
271 | ) as bar:
272 | chunk_size = 1024 # 减小块大小到1KB
273 | for chunk in r.iter_content(chunk_size=chunk_size):
274 | retries = 3
275 | for _ in range(retries):
276 | try:
277 | size = f.write(chunk)
278 | bar.update(size / (1024 * 1024))
279 | break
280 | except IOError as e:
281 | if _ == retries - 1: # 最后一次尝试
282 | raise
283 | continue
284 | except Exception as e:
285 | log_error("文件下载错误", str(e), song_info=song_info)
286 | if os.path.exists(song_filename):
287 | os.remove(song_filename)
288 | raise
289 |
290 | # 确保文件下载完成后再进行元数据注入
291 | if os.path.getsize(song_filename) == total_size:
292 | # 注入元数据
293 | try:
294 | cover_data = requests.get(album_pic_url).content
295 | inject_metadata(song_filename, song_info, lyrics, cover_data)
296 | except Exception as e:
297 | log_error("元数据注入错误", str(e), song_info=song_info)
298 | raise
299 |
300 | # 用户选择下载歌词文件处理
301 | if lyric_option == '1':
302 | lyric_filename = os.path.join(folder_name, f"{album_name} - {song_name} - {artist_name}.lrc")
303 | with open(lyric_filename, 'w', encoding='utf-8') as f:
304 | f.write(lyrics)
305 | print(f"{GREEN}歌词已保存{RESET}:{lyric_filename}")
306 |
307 | print(f"{GREEN}下载完成{RESET}:{song_filename}")
308 | return True
309 | else:
310 | raise Exception("文件大小不匹配,下载可能不完整。")
311 |
312 | except Exception as e:
313 | log_error("下载处理错误", str(e), song_info=song_info)
314 | if attempt < retries - 1:
315 | print(f"{YELLOW}第{attempt + 1}次尝试失败,准备重试...{RESET}")
316 | continue
317 | else:
318 | raise
319 |
320 | return False
321 |
322 | # 批量下载歌曲
323 | def download_all(song_ids, folder_name, quality, lyric_option, max_workers):
324 | failed_songs = []
325 | with ThreadPoolExecutor(max_workers=max_workers) as executor:
326 | futures = {executor.submit(download_song, sid, quality, folder_name, lyric_option): sid for sid in song_ids}
327 | for future in as_completed(futures):
328 | song_id = futures[future]
329 | try:
330 | if not future.result():
331 | failed_songs.append(song_id)
332 | except Exception as e:
333 | log_error("批量下载错误", f"歌曲ID {song_id} 下载失败: {str(e)}")
334 | failed_songs.append(song_id)
335 |
336 | if failed_songs:
337 | print(f"\n{YELLOW}以下歌曲下载失败:{RESET}")
338 | for sid in failed_songs:
339 | print(f"- {sid}")
340 | print("详细错误信息请查看 fail.log")
341 |
342 | def debug_response(response):
343 | """调试响应内容"""
344 | print(f"\n{YELLOW}Debug 信息:{RESET}")
345 | print(f"状态码: {response.status_code}")
346 | print(f"响应头: {dict(response.headers)}")
347 | print(f"响应内容: {response.text[:200]}...") # 只显示前200个字符
348 |
349 | def safe_request(url, error_prefix="请求失败"):
350 | """安全的请求处理"""
351 | try:
352 | response = requests.get(url, headers=headers, timeout=10)
353 | response.raise_for_status()
354 |
355 | if not response.text:
356 | raise ValueError(f"{error_prefix}: 服务器返回空响应")
357 |
358 | try:
359 | return response.json()
360 | except json.JSONDecodeError:
361 | debug_response(response)
362 | raise ValueError(f"{error_prefix}: 服务器返回非JSON数据")
363 |
364 | except requests.exceptions.RequestException as e:
365 | print(f"{RED}{error_prefix}: {str(e)}{RESET}")
366 | raise
367 | except Exception as e:
368 | print(f"{RED}{error_prefix}: {str(e)}{RESET}")
369 | raise
370 |
371 | # 主程序
372 | download_type, id_value = choose_download_type()
373 | quality = choose_quality()
374 | lyric_option = choose_lyric_option()
375 | max_workers = choose_concurrent_downloads()
376 |
377 | if download_type == 'artist':
378 | try:
379 | print(f"{YELLOW}正在获取歌手专辑列表...{RESET}")
380 | artist_albums_url = f"{ncmapi}/artist/album?id={id_value}"
381 | artist_albums_res = safe_request(artist_albums_url, "获取歌手专辑列表失败")
382 |
383 | if not artist_albums_res.get('hotAlbums'):
384 | print(f"{YELLOW}未找到任何专辑。{RESET}")
385 | exit(0)
386 |
387 | albums = artist_albums_res['hotAlbums']
388 | print(f"{GREEN}找到 {len(albums)} 张专辑{RESET}")
389 |
390 | for album in albums:
391 | try:
392 | album_name = album['name']
393 | folder_name = f"{album['artist']['name']} - {album_name}"
394 | os.makedirs(folder_name, exist_ok=True)
395 | print(f"\n{GREEN}正在获取专辑 {album_name} 的曲目...{RESET}")
396 |
397 | album_tracks_url = f"{ncmapi}/album?id={album['id']}"
398 | album_tracks_res = safe_request(
399 | album_tracks_url,
400 | f"获取专辑 {album_name} 曲目列表失败"
401 | )
402 |
403 | if not album_tracks_res.get('songs'):
404 | print(f"{YELLOW}专辑 {album_name} 中未找到歌曲。{RESET}")
405 | continue
406 |
407 | song_ids = [str(track['id']) for track in album_tracks_res['songs']]
408 | print(f"{GREEN}找到 {len(song_ids)} 首歌曲{RESET}")
409 | download_all(song_ids, folder_name, quality, lyric_option, max_workers)
410 |
411 | except Exception as e:
412 | log_error("专辑处理错误", str(e))
413 | print(f"{RED}处理专辑 {album_name} 时出错: {str(e)}{RESET}")
414 | continue
415 |
416 | except Exception as e:
417 | log_error("歌手专辑列表获取错误", str(e))
418 | print(f"{RED}获取歌手专辑列表失败: {str(e)}{RESET}")
419 | exit(1)
420 |
421 | elif download_type == 'song':
422 | download_song(id_value, quality, "", lyric_option)
423 | elif download_type == 'playlist':
424 | playlist_url = f"{ncmapi}/playlist/track/all?id={id_value}"
425 | playlist_res = requests.get(playlist_url, headers=headers).json()
426 | if 'songs' not in playlist_res:
427 | raise KeyError("API响应中缺少'songs'字段")
428 | song_ids = [str(song['id']) for song in playlist_res['songs']]
429 | folder_name = f"Playlist - {id_value}"
430 | os.makedirs(folder_name, exist_ok=True)
431 | download_all(song_ids, folder_name, quality, lyric_option, max_workers)
432 | elif download_type == 'album':
433 | album_tracks_url = f"{ncmapi}/album?id={id_value}"
434 | album_tracks_res = requests.get(album_tracks_url, headers=headers).json()
435 | if 'songs' not in album_tracks_res:
436 | raise KeyError("API响应中缺少'songs'字段")
437 | song_ids = [str(track['id']) for track in album_tracks_res['songs']]
438 | album_name = album_tracks_res['album']['name']
439 | folder_name = f"专辑 - {album_name}"
440 | os.makedirs(folder_name, exist_ok=True)
441 | download_all(song_ids, folder_name, quality, lyric_option, max_workers)
442 |
443 | print(f"{GREEN}下载完成!请检查文件夹。{RESET}")
444 | print("下载失败的歌曲请查看fail.log文件。")
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
2 | tqdm
3 | mutagen
--------------------------------------------------------------------------------