初始提交:vipsong 音乐加密格式转码工具
This commit is contained in:
BIN
Binary file not shown.
@@ -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 需要客户端密钥暂不支持
|
||||||
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
pycryptodome>=3.20
|
||||||
|
mutagen>=1.47
|
||||||
|
pydub>=0.25.1
|
||||||
+405
@@ -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"<I", data[pos:pos+4])[0]
|
||||||
|
|
||||||
|
def ncm_stream_bytes(key):
|
||||||
|
sbox = bytearray(range(256))
|
||||||
|
j = 0
|
||||||
|
klen = len(key)
|
||||||
|
for i in range(256):
|
||||||
|
j = (j + sbox[i] + key[i % klen]) & 0xFF
|
||||||
|
sbox[i], sbox[j] = sbox[j], sbox[i]
|
||||||
|
box = bytearray(256)
|
||||||
|
for i in range(256):
|
||||||
|
box[i] = sbox[(sbox[i] + sbox[(i + sbox[i]) & 0xFF]) & 0xFF]
|
||||||
|
return bytes(box)
|
||||||
|
|
||||||
|
def ncm_decrypt_audio(data, box):
|
||||||
|
reps = (len(data) + 256) // 256 + 1
|
||||||
|
stream = (box * reps)[1:1 + len(data)]
|
||||||
|
return (int.from_bytes(data, b"little") ^ int.from_bytes(stream, b"little")).to_bytes(len(data), b"little")
|
||||||
|
|
||||||
|
def decrypt_ncm(file_path):
|
||||||
|
data = Path(file_path).read_bytes()
|
||||||
|
if len(data) < 0x2C or data[:8] != NCM_MAGIC:
|
||||||
|
raise ValueError("Not a valid NCM file")
|
||||||
|
pos = 10
|
||||||
|
key_len = _u32le(data, pos); pos += 4
|
||||||
|
key_blob = bytearray(data[pos:pos+key_len])
|
||||||
|
pos += key_len
|
||||||
|
for i in range(len(key_blob)):
|
||||||
|
key_blob[i] ^= 0x64
|
||||||
|
key_plain = unpad(AES.new(CORE_KEY, AES.MODE_ECB).decrypt(bytes(key_blob)), 16)
|
||||||
|
rc4_key = key_plain[17:]
|
||||||
|
meta_len = _u32le(data, pos); pos += 4
|
||||||
|
meta = {}
|
||||||
|
if meta_len:
|
||||||
|
meta_xor = bytes(b ^ 0x63 for b in data[pos:pos+meta_len])
|
||||||
|
pos += meta_len
|
||||||
|
if meta_xor.startswith(META_PREFIX):
|
||||||
|
payload = base64.b64decode(meta_xor[len(META_PREFIX):])
|
||||||
|
plain = unpad(AES.new(META_KEY, AES.MODE_ECB).decrypt(payload), 16)
|
||||||
|
meta = json.loads(plain[6:].decode("utf-8", "replace"))
|
||||||
|
pos += 5
|
||||||
|
cover = None
|
||||||
|
if pos + 8 <= len(data):
|
||||||
|
image_space = _u32le(data, pos); pos += 4
|
||||||
|
image_size = _u32le(data, pos); pos += 4
|
||||||
|
if image_size and pos + image_size <= len(data):
|
||||||
|
cover = data[pos:pos+image_size]
|
||||||
|
if pos + image_space <= len(data):
|
||||||
|
pos += image_space
|
||||||
|
else:
|
||||||
|
pos = len(data)
|
||||||
|
box = ncm_stream_bytes(rc4_key)
|
||||||
|
audio = ncm_decrypt_audio(data[pos:], box)
|
||||||
|
container = _sniff_container(audio[:64])
|
||||||
|
if not container:
|
||||||
|
container = "mp3"
|
||||||
|
tags = {}
|
||||||
|
if meta:
|
||||||
|
if meta.get("musicName"):
|
||||||
|
tags["title"] = str(meta["musicName"])
|
||||||
|
artists = meta.get("artist") or []
|
||||||
|
if artists:
|
||||||
|
if isinstance(artists[0], (list, tuple)):
|
||||||
|
tags["artist"] = str(artists[0][0])
|
||||||
|
else:
|
||||||
|
tags["artist"] = str(artists[0])
|
||||||
|
if meta.get("album"):
|
||||||
|
tags["album"] = str(meta["album"])
|
||||||
|
return audio, container, tags, cover
|
||||||
|
|
||||||
|
KGM_MAGIC = bytes([0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
|
||||||
|
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14])
|
||||||
|
VPR_MAGIC = bytes([0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
|
||||||
|
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31])
|
||||||
|
HEADER_LEN = 1024
|
||||||
|
OWN_KEY_LEN = 17
|
||||||
|
BLOCK = 16
|
||||||
|
MEND_TABLE = bytes([
|
||||||
|
0xB8, 0xD5, 0x3D, 0xB2, 0xE9, 0xAF, 0x78, 0x8C, 0x83, 0x33, 0x71, 0x51, 0x76, 0xA0, 0xCD, 0x37,
|
||||||
|
0x2F, 0x3E, 0x35, 0x8D, 0xA9, 0xBE, 0x98, 0xB7, 0xE7, 0x8C, 0x22, 0xCE, 0x5A, 0x61, 0xDF, 0x68,
|
||||||
|
0x69, 0x89, 0xFE, 0xA5, 0xB6, 0xDE, 0xA9, 0x77, 0xFC, 0xC8, 0xBD, 0xBD, 0xE5, 0x6D, 0x3E, 0x5A,
|
||||||
|
0x36, 0xEF, 0x69, 0x4E, 0xBE, 0xE1, 0xE9, 0x66, 0x1C, 0xF3, 0xD9, 0x02, 0xB6, 0xF2, 0x12, 0x9B,
|
||||||
|
0x44, 0xD0, 0x6F, 0xB9, 0x35, 0x89, 0xB6, 0x46, 0x6D, 0x73, 0x82, 0x06, 0x69, 0xC1, 0xED, 0xD7,
|
||||||
|
0x85, 0xC2, 0x30, 0xDF, 0xA2, 0x62, 0xBE, 0x79, 0x2D, 0x62, 0x62, 0x3D, 0x0D, 0x7E, 0xBE, 0x48,
|
||||||
|
0x89, 0x23, 0x02, 0xA0, 0xE4, 0xD5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xFD, 0x16, 0x3A, 0x21, 0x3B,
|
||||||
|
0x16, 0x0F, 0xC3, 0xB2, 0xBB, 0xB3, 0xE2, 0xBA, 0x3A, 0x3D, 0x13, 0xEC, 0xF6, 0x01, 0x45, 0x84,
|
||||||
|
0xA5, 0x70, 0x0F, 0x93, 0x49, 0x0C, 0x64, 0xCD, 0x31, 0xD5, 0xCC, 0x4C, 0x07, 0x01, 0x9E, 0x00,
|
||||||
|
0x1A, 0x23, 0x90, 0xBF, 0x88, 0x1E, 0x3B, 0xAB, 0xA6, 0x3E, 0xC4, 0x73, 0x47, 0x10, 0x7E, 0x3B,
|
||||||
|
0x5E, 0xBC, 0xE3, 0x00, 0x84, 0xFF, 0x09, 0xD4, 0xE0, 0x89, 0x0F, 0x5B, 0x58, 0x70, 0x4F, 0xFB,
|
||||||
|
0x65, 0xD8, 0x5C, 0x53, 0x1B, 0xD3, 0xC8, 0xC6, 0xBF, 0xEF, 0x98, 0xB0, 0x50, 0x4F, 0x0F, 0xEA,
|
||||||
|
0xE5, 0x83, 0x58, 0x8C, 0x28, 0x2C, 0x84, 0x67, 0xCD, 0xD0, 0x9E, 0x47, 0xDB, 0x27, 0x50, 0xCA,
|
||||||
|
0xF4, 0x63, 0x63, 0xE8, 0x97, 0x7F, 0x1B, 0x4B, 0x0C, 0xC2, 0xC1, 0x21, 0x4C, 0xCC, 0x58, 0xF5,
|
||||||
|
0x94, 0x52, 0xA3, 0xF3, 0xD3, 0xE0, 0x68, 0xF4, 0x00, 0x23, 0xF3, 0x5E, 0x0A, 0x7B, 0x93, 0xDD,
|
||||||
|
0xAB, 0x12, 0xB2, 0x13, 0xE8, 0x84, 0xD7, 0xA7, 0x9F, 0x0F, 0x32, 0x4C, 0x55, 0x1D, 0x04, 0x36,
|
||||||
|
0x52, 0xDC, 0x03, 0xF3, 0xF9, 0x4E, 0x42, 0xE9, 0x3D, 0x61, 0xEF, 0x7C, 0xB6, 0xB3, 0x93, 0x50,
|
||||||
|
])
|
||||||
|
|
||||||
|
def _scramble(value):
|
||||||
|
return value ^ ((value & 0x0F) << 4)
|
||||||
|
|
||||||
|
class KgmDecoder:
|
||||||
|
def __init__(self, key_path):
|
||||||
|
import lzma
|
||||||
|
self.pub_key = lzma.decompress(Path(key_path).read_bytes())
|
||||||
|
self._phase_cache = {}
|
||||||
|
def _phase_tables(self, phase, own_key, pub_key):
|
||||||
|
cache_key = (phase, own_key, pub_key)
|
||||||
|
if cache_key in self._phase_cache:
|
||||||
|
return self._phase_cache[cache_key]
|
||||||
|
mend = len(MEND_TABLE)
|
||||||
|
columns = []
|
||||||
|
for col in range(BLOCK):
|
||||||
|
own_idx = (phase * BLOCK + col) % OWN_KEY_LEN
|
||||||
|
pub_idx = (phase * BLOCK + col) % mend
|
||||||
|
table = []
|
||||||
|
for pub_value in range(256):
|
||||||
|
xormask = _scramble(pub_value ^ MEND_TABLE[pub_idx])
|
||||||
|
table.append(bytes(_scramble(v ^ own_key[own_idx]) ^ xormask for v in range(256)))
|
||||||
|
columns.append(table)
|
||||||
|
tables = tuple(columns)
|
||||||
|
self._phase_cache[cache_key] = tables
|
||||||
|
return tables
|
||||||
|
def decode(self, file_path):
|
||||||
|
data = Path(file_path).read_bytes()
|
||||||
|
if len(data) < HEADER_LEN + 16:
|
||||||
|
raise ValueError("File too short for KGM/VPR")
|
||||||
|
header = data[:HEADER_LEN]
|
||||||
|
magic = header[:16]
|
||||||
|
if magic != KGM_MAGIC and magic != VPR_MAGIC:
|
||||||
|
raise ValueError("Not a valid KGM/VPR file")
|
||||||
|
audio_offset = struct.unpack("<I", header[0x10:0x14])[0]
|
||||||
|
crypto_version = struct.unpack("<I", header[0x14:0x18])[0]
|
||||||
|
crypto_test = header[0x1C:0x2C]
|
||||||
|
if crypto_version == 5:
|
||||||
|
raise ValueError("KGG v5 not supported")
|
||||||
|
own_key = crypto_test + b"\x00"
|
||||||
|
audio = bytearray(data[audio_offset:])
|
||||||
|
pub_len = len(self.pub_key)
|
||||||
|
out = bytearray(len(audio))
|
||||||
|
block_count = (len(audio) + BLOCK - 1) // BLOCK
|
||||||
|
for block_idx in range(block_count):
|
||||||
|
if block_idx >= 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] <files...>")
|
||||||
|
print(" Options: -f <format> 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(" <filepath> Convert file or glob pattern (e.g. *.ncm)")
|
||||||
|
print(" -f <format> 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: <filepath> [-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()
|
||||||
Reference in New Issue
Block a user