feat: sync catalog, server, and MusicFree updates

This commit is contained in:
2026-07-16 18:43:22 +08:00
parent 069af30dba
commit e9bb3df906
58 changed files with 4409 additions and 206 deletions
@@ -8,5 +8,6 @@ MUSIC_SERVER_ADMIN_USERNAME=admin
MUSIC_SERVER_ADMIN_PASSWORD_HASH=sha256$replace-with-sha256-hex
MUSIC_SERVER_SECRET_ENCRYPTION_KEY=replace-with-a-strong-secret
MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS=600
MUSIC_SERVER_STREAM_TOKEN_TTL_SECONDS=3600
MUSICFREE_VERSION_JSON=/app/release/version.json
MUSICFREE_APK_PATH=/app/release/MusicFree_latest_release_universal.apk
+1 -1
View File
@@ -1,5 +1,5 @@
param(
[string]$HostName = "192.168.5.43",
[string]$HostName = "192.168.5.11",
[int]$Port = 222,
[string]$User = "xiaoming",
[string]$RemoteAppHome = "/volume4/Music_Cloud/Music_Server",
+1 -1
View File
@@ -23,7 +23,7 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Upload Music_Server to NAS staging and trigger deploy_and_restart.sh"
)
parser.add_argument("--host", default="192.168.5.43")
parser.add_argument("--host", default="192.168.5.11")
parser.add_argument("--port", type=int, default=222)
parser.add_argument("--user", default="xiaoming")
parser.add_argument(
@@ -111,6 +111,39 @@ def _build_stream_url(*, token: str, resolved: dict) -> str:
return f"/mf/v1/media/stream/{token}"
def _resolve_stream_source(*, song_id: int, locator: str, quality: str, settings) -> dict:
resolver = MediaResolver(db_path=settings.catalog_db_path)
if locator:
try:
return resolver.resolve_by_locator(song_id=song_id, locator=locator)
except LookupError:
pass
return resolver.resolve(song_id=song_id, quality=quality)
def _is_origin_stream_reachable(public_url: str) -> bool:
return _is_cache_url_reachable(public_url)
def _pick_viable_origin_source(*, song_id: int, quality: str, settings, preferred_locator: str = "") -> dict:
resolver = MediaResolver(db_path=settings.catalog_db_path)
candidates = resolver.resolve_candidates(song_id=song_id, quality=quality)
if not candidates:
raise LookupError("no playable source found")
def _sort_key(item: dict) -> tuple[int, int, str]:
locator = str(item.get("locator") or "")
return (0 if preferred_locator and locator == preferred_locator else 1, 0, locator)
for candidate in sorted(candidates, key=_sort_key):
if candidate.get("backend_type") == "local_fs":
return candidate
public_url = str(candidate.get("public_url") or "")
if public_url and _is_origin_stream_reachable(public_url):
return candidate
return candidates[0]
@router.post("/media/resolve")
def resolve_media(payload: dict) -> dict:
settings = get_settings()
@@ -146,6 +179,8 @@ def resolve_media(payload: dict) -> dict:
secret=settings.access_token,
song_id=song_id,
locator=token_locator,
quality=quality,
ttl_seconds=settings.stream_token_ttl_seconds,
)
selected_source = cached_source or fallback_source or {}
selected_size = None
@@ -175,6 +210,7 @@ def stream_media(token: str, request: Request, ext: str | None = None):
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
song_id = int(parsed["song_id"])
quality = str(parsed.get("quality") or "standard")
cache_service = _cache_service(settings)
cached_source = cache_service.resolve_cached_source(song_id=song_id)
@@ -183,9 +219,11 @@ def stream_media(token: str, request: Request, ext: str | None = None):
return RedirectResponse(url=str(cached_source["public_url"]), status_code=307)
try:
resolved = MediaResolver(db_path=settings.catalog_db_path).resolve_by_locator(
resolved = _resolve_stream_source(
song_id=song_id,
locator=str(parsed["locator"]),
locator=str(parsed.get("locator") or ""),
quality=quality,
settings=settings,
)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -237,6 +275,17 @@ def stream_media(token: str, request: Request, ext: str | None = None):
)
public_url = resolved.get("public_url")
if public_url and not _is_origin_stream_reachable(str(public_url)):
try:
resolved = _pick_viable_origin_source(
song_id=song_id,
quality=quality,
settings=settings,
preferred_locator=str(resolved.get("locator") or ""),
)
except LookupError:
pass
public_url = resolved.get("public_url")
if not public_url:
raise HTTPException(status_code=404, detail="public stream url not found")
cache_service.record_stream_play(song_id=song_id, stream_token=token)
@@ -7,21 +7,24 @@ class MediaResolver:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
def resolve(self, song_id: int, quality: str) -> dict:
def resolve_candidates(self, song_id: int, quality: str) -> list[dict]:
with closing(connect_sqlite(self._db_path)) as conn:
row = conn.execute(
rows = conn.execute(
"""
select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url
select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url, is_primary
from catalog_track_files
where song_id = ? and status = 'active'
order by case when quality_label = ? then 0 else 1 end, is_primary desc
limit 1
order by case when quality_label = ? then 0 else 1 end, is_primary desc, locator asc
""",
(song_id, quality),
).fetchone()
if row is None:
).fetchall()
return [dict(row) for row in rows]
def resolve(self, song_id: int, quality: str) -> dict:
rows = self.resolve_candidates(song_id=song_id, quality=quality)
if not rows:
raise LookupError("no playable source found")
return dict(row)
return rows[0]
def resolve_by_locator(self, song_id: int, locator: str) -> dict:
with closing(connect_sqlite(self._db_path)) as conn:
@@ -13,10 +13,18 @@ def _sign_payload(secret: str, payload_json: str) -> str:
).hexdigest()
def create_stream_token(secret: str, song_id: int, locator: str, ttl_seconds: int = 300) -> str:
def create_stream_token(
secret: str,
song_id: int,
locator: str,
*,
quality: str | None = None,
ttl_seconds: int = 3600,
) -> str:
payload = {
"song_id": int(song_id),
"locator": str(locator),
"quality": str(quality or "standard"),
"expires_at": int(time.time()) + int(ttl_seconds),
}
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
@@ -40,15 +48,15 @@ def parse_stream_token(secret: str, token: str) -> dict:
raise ValueError("invalid stream token")
song_id = int(payload["song_id"])
locator = str(payload["locator"])
locator = str(payload.get("locator") or "")
quality = str(payload.get("quality") or "standard")
expires_at = int(payload["expires_at"])
if not locator:
raise ValueError("invalid stream token")
if expires_at < int(time.time()):
raise ValueError("stream token expired")
return {
"song_id": song_id,
"locator": locator,
"quality": quality,
"expires_at": expires_at,
}
except ValueError:
@@ -28,6 +28,7 @@ class Settings:
admin_password_hash: str
secret_encryption_key: str
cache_reconcile_interval_seconds: int
stream_token_ttl_seconds: int
musicfree_version_json_path: str
musicfree_apk_path: str
@@ -78,6 +79,10 @@ def get_settings() -> Settings:
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS",
600,
),
stream_token_ttl_seconds=_env_int(
"MUSIC_SERVER_STREAM_TOKEN_TTL_SECONDS",
3600,
),
musicfree_version_json_path=os.getenv(
"MUSICFREE_VERSION_JSON",
str(musicfree_release_dir / "version.json"),
@@ -84,4 +84,3 @@ class AppUpdateRouteTests(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
+201
View File
@@ -1,5 +1,6 @@
import sqlite3
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
@@ -156,6 +157,42 @@ class MfMediaRouteTests(unittest.TestCase):
self.assertIn("/mf/v1/media/stream/", payload["stream"]["url"])
self.assertTrue(payload["stream"]["url"].endswith(".flac"))
def test_media_resolve_issues_longer_lived_stream_token_for_background_queueing(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
stream_url = resolve_response.json()["stream"]["url"]
token = stream_url.rsplit("/", 1)[-1].split(".", 1)[0]
from music_server.services.stream_tokens import parse_stream_token
parsed = parse_stream_token(secret="dev-token", token=token)
remaining = parsed["expires_at"] - int(time.time())
self.assertGreaterEqual(
remaining,
1800,
"stream token ttl should be long enough for background playback queueing",
)
def test_media_stream_redirects_to_public_url(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
@@ -353,6 +390,170 @@ class MfMediaRouteTests(unittest.TestCase):
stream_response.headers.get("location"),
)
def test_media_stream_falls_back_to_current_active_source_when_token_locator_is_stale(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table catalog_track_files (
song_id integer not null,
quality_label text not null,
ext text not null,
file_size_bytes integer not null,
backend_type text not null,
backend_name text not null,
locator text not null,
public_url text,
status text not null,
is_primary integer not null
)
"""
)
conn.execute(
"""
insert into catalog_track_files (
song_id, quality_label, ext, file_size_bytes, backend_type, backend_name,
locator, public_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
3476,
"super",
"flac",
42345678,
"object_storage",
"main-s3",
"music/netease/new.flac",
"https://cdn.example/new.flac",
"active",
1,
),
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
},
clear=False,
):
client = TestClient(create_app())
from music_server.services.stream_tokens import create_stream_token
stale_token = create_stream_token(
secret="dev-token",
song_id=3476,
locator="music/netease/old.flac",
ttl_seconds=3600,
)
stream_response = client.get(
f"/mf/v1/media/stream/{stale_token}.flac",
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/new.flac",
stream_response.headers.get("location"),
)
def test_media_stream_falls_back_to_next_origin_source_when_selected_public_url_is_unreachable(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table catalog_track_files (
song_id integer not null,
quality_label text not null,
ext text not null,
file_size_bytes integer not null,
backend_type text not null,
backend_name text not null,
locator text not null,
public_url text,
status text not null,
is_primary integer not null
)
"""
)
conn.executemany(
"""
insert into catalog_track_files (
song_id, quality_label, ext, file_size_bytes, backend_type, backend_name,
locator, public_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
3476,
"super",
"flac",
42345678,
"object_storage",
"main-s3",
"music/netease/first.flac",
"https://cdn.example/first.flac",
"active",
1,
),
(
3476,
"super",
"flac",
42345678,
"object_storage",
"backup-s3",
"music/netease/second.flac",
"https://cdn.example/second.flac",
"active",
0,
),
],
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
},
clear=False,
):
client = TestClient(create_app())
resolve_response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, resolve_response.status_code)
with patch(
"music_server.routes.mf_media._is_origin_stream_reachable",
side_effect=lambda url: url.endswith("second.flac"),
):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/second.flac",
stream_response.headers.get("location"),
)
def test_media_stream_falls_back_when_cached_public_url_is_unreachable(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"