feat: sync catalog, server, and MusicFree updates
This commit is contained in:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user