commit 6f7e011686d2e56bcb377a1a5c7f59081d07bf09 Author: ypshidifu Date: Thu Sep 17 12:55:30 2026 +0800 Initial commit: vipsong music format converter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58e75f8 Binary files /dev/null and b/.gitignore differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..740a61a --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# vipsong - 酷狗/网易云音乐加密格式转码工具 + +CLI 工具,将酷狗加密格式 (.kgm/.kgma/.vpr) 和网易云加密格式 (.ncm) 解密转码为常见音频格式。 + +## 安装依赖 + +pip install -r requirements.txt + +确保系统已安装 FFmpeg(用于格式转换)。 + +## 酷狗密钥 + +首次使用前,需获取酷狗密钥文件 kugou_key.xz: + +curl -o kugou_key.xz https://raw.githubusercontent.com/HRuiCcc/music-geshizhuanhuan/master/assets/kugou_key.xz + +## 使用方法 + +### 命令行模式 + +python vipsong.py *.ncm +python vipsong.py ./music/song.kgm +python vipsong.py *.ncm -f flac -d +python vipsong.py song.ncm -d +python vipsong.py ./downloads/*.ncm ./music/*.kgm + +### 交互模式 + +python vipsong.py + +vipsong> *.ncm # 转换当前目录全部 ncm 文件 +vipsong> ./xx/xx/xx.kgm # 转换指定 kgm 文件 +vipsong> song.ncm -d # 转码后删除原 ncm 文件 +vipsong> /get # 查看转码统计 +vipsong> /exit # 退出 + +## 命令说明 + +| 命令/参数 | 说明 | +|-----------|------| +| *.ncm | 转换当前目录全部 ncm 文件 | +| ./xx/xx/xx.kgm | 转换指定路径的文件 | +| -f <格式> | 指定输出格式:mp3/flac/wav/ogg/m4a/original(默认 mp3)| +| -d | 转码成功后删除原加密文件 | +| /exit | 退出交互模式 | +| /get | 显示转码统计(成功数、失败数、文件列表) | + +## 支持格式 + +| 输入格式 | 说明 | 输出格式 | +|----------|------|----------| +| .ncm | 网易云音乐加密格式 | mp3/flac/wav/ogg/m4a | +| .kgm/.kgma/.vpr | 酷狗音乐加密格式 (v3/v4) | mp3/flac/wav/ogg/m4a | + +## 注意 + +- 转换后保留原始 ID3 标签(歌名、艺术家、专辑、封面) +- KGM 格式需要 kugou_key.xz 密钥文件(存放在项目根目录) +- 仅支持 KGM v3/v4,v5 需要客户端密钥暂不支持 diff --git a/kugou_key.xz b/kugou_key.xz new file mode 100644 index 0000000..c7db7e5 Binary files /dev/null and b/kugou_key.xz differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..45b4a58 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pycryptodome>=3.20 +mutagen>=1.47 +pydub>=0.25.1 diff --git a/vipsong.py b/vipsong.py new file mode 100644 index 0000000..f5a3bec --- /dev/null +++ b/vipsong.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +import os, sys, glob, struct, base64, json +from pathlib import Path +from typing import Optional +from Crypto.Cipher import AES +from Crypto.Util.Padding import unpad +from mutagen.mp3 import MP3 +from mutagen.flac import FLAC +from mutagen.oggvorbis import OggVorbis +from mutagen.id3 import ID3, TIT2, TPE1, TALB, APIC +from pydub import AudioSegment + +NCM_MAGIC = b"CTENFDAM" +CORE_KEY = b"hzHRAmso5kInbaxW" +META_PREFIX = b"163 key(D't modify):" + +def _u32le(data, pos): + return struct.unpack(b"= pub_len: + raise ValueError("Key file mismatch") + phase = block_idx % 17 + tables = self._phase_tables(phase, own_key, self.pub_key) + pub_value = self.pub_key[block_idx] + start = block_idx * BLOCK + end = min(start + BLOCK, len(audio)) + for col, offset in enumerate(range(start, end)): + out[offset] = tables[col][pub_value][audio[offset]] + payload = bytes(out) + container = _sniff_container(payload[:64]) + return payload, container, {}, None + +def _resolve_output(file_path, output_fmt): + p = Path(file_path) + ext = _extension_for(output_fmt) if output_fmt != "original" else p.suffix.lstrip(".") or "mp3" + return str(p.with_suffix("." + ext)) + +def _add_tags(output_path, tags, cover=None): + p = Path(output_path) + ext = p.suffix.lower() + try: + if ext == ".mp3": + audio = MP3(output_path, ID3=ID3) + if "title" in tags: + audio["TIT2"] = TIT2(encoding=3, text=tags["title"]) + if "artist" in tags: + audio["TPE1"] = TPE1(encoding=3, text=tags["artist"]) + if "album" in tags: + audio["TALB"] = TALB(encoding=3, text=tags["album"]) + if cover: + mime = "image/jpeg" if cover[:3] == b"\xff\xd8\xff" else "image/png" + audio["APIC"] = APIC(encoding=3, mime=mime, type=3, desc="cover", data=cover) + audio.save() + elif ext == ".flac": + if p.exists(): + audio = FLAC(output_path) + else: + audio = FLAC() + if "title" in tags: audio["title"] = tags["title"] + if "artist" in tags: audio["artist"] = tags["artist"] + if "album" in tags: audio["album"] = tags["album"] + if cover: + audio["metadata_block_picture"] = base64.b64encode(cover).decode() + audio.save() + elif ext == ".ogg": + audio = OggVorbis(output_path) + if "title" in tags: audio["TITLE"] = tags["title"] + if "artist" in tags: audio["ARTIST"] = tags["artist"] + if "album" in tags: audio["ALBUM"] = tags["album"] + audio.save() + except Exception: + pass + +def convert_file(file_path, output_fmt, delete_flag): + global stats, kgm_decoder + file_path = str(Path(file_path).resolve()) + if not Path(file_path).exists(): + print(" [FAIL] File not found: " + file_path) + stats["failed"] += 1 + stats["files"].append((file_path, False)) + return + p = Path(file_path) + suffix = p.suffix.lower() + if suffix == ".ncm": + try: + raw, container, tags, cover = decrypt_ncm(file_path) + except Exception as e: + print(" [FAIL] NCM decrypt failed: " + file_path + " -> " + str(e)) + stats["failed"] += 1 + stats["files"].append((file_path, False)) + return + elif suffix in (".kgm", ".kgma", ".vpr", ".kgm.flac", ".vpr.flac"): + if kgm_decoder is None: + if not KUGOU_KEY_PATH.exists(): + print(" [FAIL] Missing kugou key file: " + str(KUGOU_KEY_PATH)) + stats["failed"] += 1 + stats["files"].append((file_path, False)) + return + try: + kgm_decoder = KgmDecoder(str(KUGOU_KEY_PATH)) + except Exception as e: + print(" [FAIL] Failed to load kugou key: " + str(e)) + stats["failed"] += 1 + stats["files"].append((file_path, False)) + return + try: + raw, container, tags, cover = kgm_decoder.decode(file_path) + except Exception as e: + print(" [FAIL] KGM decrypt failed: " + file_path + " -> " + str(e)) + stats["failed"] += 1 + stats["files"].append((file_path, False)) + return + else: + print(" [SKIP] Unsupported format: " + suffix) + stats["failed"] += 1 + stats["files"].append((file_path, False)) + return + out_fmt = container if output_fmt == "original" else output_fmt + output_path = _resolve_output(file_path, out_fmt) + try: + _convert_audio(raw, container, out_fmt, output_path) + except Exception as e: + print(" [FAIL] Conversion failed: " + file_path + " -> " + str(e)) + stats["failed"] += 1 + stats["files"].append((file_path, False)) + return + try: + _add_tags(output_path, tags, cover) + except Exception as e: + print(" [WARN] Tag write failed: " + str(e)) + if delete_flag: + try: + os.remove(file_path) + print(" [OK] " + file_path + " -> " + output_path + " (original deleted)") + except Exception as e: + print(" [OK] " + file_path + " -> " + output_path + " (delete failed: " + str(e) + ")") + else: + print(" [OK] " + file_path + " -> " + output_path) + stats["success"] += 1 + stats["files"].append((file_path, True)) + +def expand_files(patterns): + result = [] + for pat in patterns: + matched = glob.glob(pat, recursive=True) + if matched: + result.extend(matched) + else: + p = Path(pat) + if p.exists(): + result.append(str(p.resolve())) + else: + result.append(pat) + return result + +def print_stats(): + print() + print("=== Conversion Stats ===") + print(" Success: " + str(stats["success"]) + " | Failed: " + str(stats["failed"])) + if stats["files"]: + print() + print(" Files:") + for fp, ok in stats["files"]: + status = "OK" if ok else "FAIL" + print(" [" + status + "] " + fp) + print() + +def main(): + global stats + if len(sys.argv) > 1: + argv = sys.argv[1:] + patterns = [] + output_fmt = "mp3" + delete_flag = False + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "-f" and i + 1 < len(argv): + output_fmt = argv[i + 1].lower() + i += 2 + elif arg == "-d": + delete_flag = True + i += 1 + elif arg.startswith("-"): + i += 1 + else: + patterns.append(arg) + i += 1 + if not patterns: + print("Usage: vipsong.py [options] ") + print(" Options: -f Output format (mp3/flac/wav/ogg/m4a/original)") + print(" -d Delete original file after conversion") + print(" Examples:") + print(" vipsong.py *.ncm") + print(" vipsong.py -f flac -d ./music/*.ncm") + print(" vipsong.py ./song.kgm") + sys.exit(1) + files = expand_files(patterns) + for f in files: + convert_file(f, output_fmt, delete_flag) + print_stats() + return + print("=* 50") + print(" vipsong - Music Format Converter") + print(" Support: .ncm (NetEase) .kgm/.vpr (KuGou)") + print(" Output: mp3 / flac / wav / ogg / m4a") + print("=* 50") + print("Commands:") + print(" Convert file or glob pattern (e.g. *.ncm)") + print(" -f Output format (default: mp3)") + print(" -d Delete original after conversion") + print(" /exit Exit") + print(" /get Show stats") + print() + while True: + try: + line = input("vipsong> ").strip() + except (EOFError, KeyboardInterrupt): + print() + print("Bye!") + break + if not line: + continue + parts = line.split() + if not parts: + continue + cmd = parts[0] + if cmd == "/exit": + print("Bye!") + break + elif cmd == "/get": + print_stats() + continue + patterns = [] + output_fmt = "mp3" + delete_flag = False + i = 1 + while i < len(parts): + if parts[i] == "-f" and i + 1 < len(parts): + output_fmt = parts[i + 1].lower() + i += 2 + elif parts[i] == "-d": + delete_flag = True + i += 1 + elif parts[i].startswith("-"): + print(" [WARN] Unknown option: " + parts[i]) + i += 1 + else: + patterns.append(parts[i]) + i += 1 + if not patterns: + print(" Usage: [-f format] [-d]") + print(" Examples: *.ncm") + print(" ./xx/xx.kgm") + print(" song.ncm -d") + print(" *.ncm -f flac") + continue + files = expand_files(patterns) + for f in files: + convert_file(f, output_fmt, delete_flag) + print() + print(" Current: Success " + str(stats["success"]) + " | Failed " + str(stats["failed"])) + print() + +if __name__ == "__main__": + main()