合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0
版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定
合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/ 自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成
同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)
文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明
验证:assembleRelease + testReleaseUnitTest 全过(39 例)
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
"""曲库同步(复刻 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
|
||||
Reference in New Issue
Block a user