"""曲库同步(复刻 App 的 MuseDbSync):login → sqlite/sync → 下载 gz → md5 校验 → 解压 → 导入清单。 要点: * **纯标准库实现 RSA/PKCS#1 v1.5**(Debian 上不需要装任何 pip 包,也不需要 openssl 命令行); * 同步前必须重新 login 拿 `muse-portal-connector` cookie——没有它 sync 直接 401; * `mconn` 与 `mws` 两个主机都能返回文件(同一后台),任选其一; * 官方库**没有** `cloud_url` 列(29 列版本),导入时只取存在的列。 """ from __future__ import annotations import base64 import gzip import hashlib import json import os import sqlite3 import threading import time import urllib.request from typing import Callable # App 里使用的 RSA 公钥(与 MuseDbSync.RSA_PUBLIC_KEY 一致) RSA_PUBLIC_KEY_B64 = ( "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB" ) CANON_LANGS = ["国语", "粤语", "闽南语", "英语", "日语", "韩语", "其它"] LANG_ALIAS = {"闽南": "闽南语", "台语": "闽南语", "H国语": "国语", "其他": "其它", "西语": "其它", "西班牙语": "其它", "法语": "其它", "俄语": "其它", "泰语": "其它", "印度": "其它", "潮汕语": "其它", "消音": "其它", "原版": "其它"} def norm_lang(v: str) -> str: k = (v or "").strip() if k in CANON_LANGS: return k if k in LANG_ALIAS: return LANG_ALIAS[k] return "其它" if k else "未标注" # ───────────────────────── 极简 DER / RSA ───────────────────────── def _der_tlv(buf: bytes, pos: int): tag = buf[pos] pos += 1 ln = buf[pos] pos += 1 if ln & 0x80: n = ln & 0x7F ln = int.from_bytes(buf[pos:pos + n], "big") pos += n start = pos return tag, start, start + ln def _der_walk(buf: bytes, start: int, end: int): """产出 (tag, start, end) 的扁平列表(含容器内部)。""" out = [] p = start while p < end: tag, s, e = _der_tlv(buf, p) out.append((tag, s, e)) if tag & 0x20: # constructed → 递归 out.extend(_der_walk(buf, s, e)) elif tag == 0x03: # BIT STRING:跳过"未使用位数"字节后继续 # SubjectPublicKeyInfo 里的 RSAPublicKey 就包在 BIT STRING 内层。 out.extend(_der_walk(buf, s + 1, e)) p = e return out def rsa_public_numbers(b64: str = RSA_PUBLIC_KEY_B64) -> tuple[int, int]: """从 SubjectPublicKeyInfo(DER) 里取出 (n, e)。""" der = base64.b64decode(b64) ints = [] for tag, s, e in _der_walk(der, 0, len(der)): if tag == 0x02: # INTEGER ints.append(int.from_bytes(der[s:e], "big")) if len(ints) < 2: raise ValueError("无法解析 RSA 公钥") return ints[-2], ints[-1] # modulus, exponent def rsa_pkcs1v15_encrypt(plaintext: bytes, n: int, e: int) -> bytes: """PKCS#1 v1.5 加密(EM = 0x00 0x02 PS 0x00 M)。""" k = (n.bit_length() + 7) // 8 if len(plaintext) > k - 11: raise ValueError("明文过长") ps_len = k - 3 - len(plaintext) ps = bytearray() while len(ps) < ps_len: b = os.urandom(1)[0] if b: # PS 必须非零 ps.append(b) em = b"\x00\x02" + bytes(ps) + b"\x00" + plaintext return pow(int.from_bytes(em, "big"), e, n).to_bytes(k, "big") def encrypt_device_id(device_id: str) -> str: n, e = rsa_public_numbers() return base64.b64encode(rsa_pkcs1v15_encrypt(json.dumps({"device_id": device_id}).encode(), n, e)).decode() # ───────────────────────── HTTP ───────────────────────── def _post_json(url: str, payload: dict, cookie: str = "", timeout: int = 60, ua: str = "MaidongKTV/1.2"): body = json.dumps(payload, separators=(",", ":")).encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/json; charset=UTF-8") req.add_header("Accept", "*/*") req.add_header("User-Agent", ua) if cookie: req.add_header("Cookie", cookie) with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() set_cookie = resp.headers.get("Set-Cookie", "") or "" code = resp.status return code, raw, set_cookie def login(cfg) -> str: """返回 `muse-portal-connector=…` cookie 字符串;失败抛异常。""" payload = { "encrypted_data": encrypt_device_id(str(cfg.get("muse_device_id"))), "ip": "172.16.32.15", "dns": "", "router": "", "subnet_mask": "255.255.255.0", "channel": "common", "mode": "ott", } code, raw, set_cookie = _post_json(str(cfg.get("muse_login_url")), payload, timeout=int(cfg.get("timeout_sec")), ua=str(cfg.get("user_agent"))) cookie = "" for part in set_cookie.split(","): for seg in part.split(";"): seg = seg.strip() if seg.startswith("muse-portal-connector="): cookie = seg if code != 200 or not cookie: raise RuntimeError(f"曲库登录失败:HTTP {code},cookie={'有' if cookie else '无'}") return cookie def remote_file(cfg) -> dict: """返回 {url, md5, day, db_vc, date, key, size?}。""" cookie = login(cfg) payload = {"last_sync_at": 0, "music_file_type": int(cfg.get("muse_music_file_type"))} code, raw, _ = _post_json(str(cfg.get("muse_sync_url")), payload, cookie, timeout=int(cfg.get("timeout_sec")), ua=str(cfg.get("user_agent"))) if code != 200: raise RuntimeError(f"曲库同步 HTTP {code}") data = json.loads(raw.decode("utf-8", "replace")) files = data.get("files") or [] if not files: raise RuntimeError("曲库同步返回空 files(服务端未授权或该设备无可用曲库)") f = files[0] return {"url": f.get("url", ""), "md5": (f.get("md5") or "").lower(), "day": f.get("day", ""), "db_vc": f.get("db_vc", ""), "date": f.get("date", 0), "key": f.get("key", "")} def version_of(info: dict) -> str: return str(info.get("date") or info.get("day") or info.get("db_vc") or "0") def download(url: str, dest: str, on_progress: Callable[[int, int], None] | None = None, timeout: int = 120): req = urllib.request.Request(url, headers={"User-Agent": "MaidongKTV/1.2"}) total = 0 with urllib.request.urlopen(req, timeout=timeout) as resp: total = int(resp.headers.get("Content-Length") or 0) have = 0 with open(dest, "wb") as fh: while True: chunk = resp.read(1 << 20) if not chunk: break fh.write(chunk) have += len(chunk) if on_progress: on_progress(have, total) return total def md5_file(path: str) -> str: h = hashlib.md5() with open(path, "rb") as fh: for chunk in iter(lambda: fh.read(1 << 20), b""): h.update(chunk) return h.hexdigest() # ───────────────────────── 导入清单 ───────────────────────── def import_from_muse_db(muse_path: str) -> list[tuple]: """把官方 muse.db 的歌曲清单(含歌手、语言)读成 store.import_library 需要的行。 关系表 `song_singer_relations.song_id` 是 **songs.id (uuid)**,不是 tid, 所以先建 uuid→tid 映射再拼歌手名。 """ conn = sqlite3.connect(f"file:{muse_path}?mode=ro", uri=True) conn.row_factory = sqlite3.Row try: cols = {r[1] for r in conn.execute("PRAGMA table_info(songs)")} has_rel = bool(conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='song_singer_relations'").fetchone()) has_singers = bool(conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='singers'").fetchone()) # 歌手名:uuid(song_id) → tid by_tid: dict[int, list[str]] = {} if has_rel and has_singers and "id" in cols: for sid, tid, nm in conn.execute( """SELECT r.song_id, s.tid, sg.name FROM song_singer_relations r JOIN songs s ON s.id = r.song_id JOIN singers sg ON sg.id = r.singer_id WHERE sg.name IS NOT NULL AND sg.name != ''"""): if tid: by_tid.setdefault(int(tid), []).append(nm) album_col = "coalesce(s.album_name,'')" if "album_name" in cols else "''" hot_col = "coalesce(s.hot_score,0)" if "hot_score" in cols else "0" tags_col = "coalesce(s.tags_str,'')" if "tags_str" in cols else "''" rows = conn.execute( f"""SELECT s.tid, s.name, s.lang, {album_col} album, s.filename, {hot_col} hot, {tags_col} tags FROM songs s WHERE s.tid IS NOT NULL AND s.tid > 0""").fetchall() out = [] for r in rows: tid = int(r["tid"]) singers = " / ".join(by_tid.get(tid, [])[:6]) out.append((tid, r["name"] or "", norm_lang(r["lang"] or ""), r["album"] or "", singers, r["filename"] or f"{tid}.ts", r["hot"], r["tags"])) return out finally: conn.close() def sync_library(cfg, store, on_progress: Callable[[str, int], None] | None = None, cancel: "threading.Event | None" = None) -> dict: """完整同步一遍:查询版本 → 需要则下载安装 → **与既有清单比对** → 导入。 整个过程挂在一条 `sync_tasks` 记录上(谁在跑、跑完新增/删除/变更各多少), 支持中途取消(`cancel` 置位后在下一个阶段边界退出)。 """ def prog(text: str, pct: int = 0): if on_progress: on_progress(text, pct) store.event("INFO", f"曲库同步:{text}") def cancelled() -> bool: return bool(cancel is not None and cancel.is_set()) cur = store.get_meta("library_version") task_id = store.sync_task_start(cur) try: info = remote_file(cfg) version = version_of(info) prog(f"远端版本 {version}(day={info.get('day')} db_vc={info.get('db_vc')}),本地版本 {cur or '无'}", 3) if cancelled(): store.sync_task_finish(task_id, "cancelled", version, error="用户取消") return {"updated": False, "cancelled": True, "version": version} if version and version == cur and store.library_meta()["songs"] > 0: prog("已是最新,跳过下载", 100) store.sync_task_finish(task_id, "done", version, {"total": store.library_meta()["songs"]}) return {"updated": False, "version": version, "songs": store.library_meta()["songs"]} except Exception as exc: store.sync_task_finish(task_id, "failed", "", error=str(exc)) raise data_dir = str(cfg.get("data_dir")) gz = os.path.join(data_dir, "muse.db.gz.download") muse_tmp = os.path.join(data_dir, "muse.db.download") os.makedirs(data_dir, exist_ok=True) if cancelled(): store.sync_task_finish(task_id, "cancelled", version, error="用户取消") return {"updated": False, "cancelled": True, "version": version} prog("开始下载曲库压缩包", 5) last = [0] def on_dl(have: int, total: int): pct = 5 + int(have * 70 / total) if total else 5 if pct - last[0] >= 2: last[0] = pct prog(f"下载中 {have // 1048576}MB/{total // 1048576}MB", pct) download(info["url"], gz, on_dl, timeout=600) prog("校验 md5", 78) got = md5_file(gz) if info["md5"] and got != info["md5"]: os.remove(gz) raise RuntimeError(f"曲库 md5 不一致 got={got} want={info['md5']}") prog("解压曲库", 82) written = 0 with gzip.open(gz, "rb") as src, open(muse_tmp, "wb") as dst: while True: chunk = src.read(1 << 20) if not chunk: break dst.write(chunk) written += len(chunk) os.remove(gz) if cancelled(): try: os.remove(muse_tmp) except Exception: pass store.sync_task_finish(task_id, "cancelled", version, error="用户取消") return {"updated": False, "cancelled": True, "version": version} prog(f"解压完成 {written // 1048576}MB,开始比对清单并导入", 90) rows = import_from_muse_db(muse_tmp) diff = store.diff_and_import(rows, version) # ★ 需求①:更新时比对清单 prog(f"清单比对完成:总 {diff['total']},新增 {diff['added']}," f"上游删除 {diff['removed']},变更 {diff['changed']}", 99) # 原始库留档,便于排错/二次导入 try: keep = os.path.join(data_dir, f"muse-{info.get('day') or version}.db") if os.path.exists(keep): os.remove(keep) os.replace(muse_tmp, keep) store.set_meta("library_file", keep) except Exception as exc: # pragma: no cover store.event("WARN", f"保留原始曲库失败:{exc}") try: os.remove(muse_tmp) except Exception: pass result = {"updated": True, "version": version, "songs": diff["total"], "day": info.get("day"), "md5": info["md5"], "diff": diff, "task_id": task_id} store.sync_task_finish(task_id, "done", version, diff) return result