Initial import: Music_Server, MusicFree, catalog-sync

This commit is contained in:
2026-05-23 16:51:14 +08:00
commit 069af30dba
847 changed files with 179878 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
import sys
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parents[1] / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
from music_server.services.token_service import TokenService
_TOKEN_CACHE: dict[tuple[str, str], str] = {}
def issue_access_token(player_db_path: str | Path, *, label: str = "test-token") -> str:
cache_key = (str(player_db_path), label)
cached = _TOKEN_CACHE.get(cache_key)
if cached is not None:
return cached
service = TokenService(str(player_db_path))
issued = service.issue_token(label=label).plaintext_token
_TOKEN_CACHE[cache_key] = issued
return issued
def auth_headers(
player_db_path: str | Path,
*,
client_id: str = "test-client",
client_label: str = "Test Client",
token: str | None = None,
include_client_id: bool = True,
include_client_label: bool = True,
) -> dict[str, str]:
issued_token = token or issue_access_token(player_db_path)
headers = {"Authorization": f"Bearer {issued_token}"}
if include_client_id:
headers["X-Music-Client-Id"] = client_id
if include_client_label:
headers["X-Music-Client-Label"] = client_label
return headers
@@ -0,0 +1,249 @@
import hashlib
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from music_server.services.cache_service import CacheService
class AdminCacheRouteTests(unittest.TestCase):
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table catalog_tracks (
song_id integer primary key,
platform text not null,
remote_song_id text not null,
name text not null,
singers text,
album text,
cover_url text,
duration_ms integer,
metadata_json text
)
"""
)
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,
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_tracks (
song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
2001,
"kuwo",
"remote-2001",
"Song 2001",
"Singer 2001",
"Album 2001",
None,
None,
None,
),
)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
2001,
"super",
"flac",
1024,
"object_storage",
"origin",
"songs/2001.flac",
"https://origin.example/2001.flac",
"active",
1,
),
)
conn.commit()
conn.close()
def _admin_env(self, player_db_path: Path, catalog_db_path: Path) -> dict[str, str]:
return {
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
"MUSIC_SERVER_ADMIN_USERNAME": "admin",
"MUSIC_SERVER_ADMIN_PASSWORD_HASH": f"sha256${hashlib.sha256('secret123'.encode('utf-8')).hexdigest()}",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
}
def test_admin_cache_api_requires_login(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict("os.environ", self._admin_env(player_db_path, catalog_db_path), clear=False):
client = TestClient(create_app())
response = client.get("/admin/api/cache/overview")
self.assertEqual(401, response.status_code)
def test_admin_login_then_manage_targets_and_reconcile(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict("os.environ", self._admin_env(player_db_path, catalog_db_path), clear=False):
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
service.upsert_heat_summary(
song_id=2001,
play_count_total=12,
play_count_30d=12,
last_played_at="2026-04-23T12:00:00+00:00",
)
client = TestClient(create_app())
login = client.post(
"/admin/session/login",
data={"username": "admin", "password": "secret123"},
follow_redirects=False,
)
self.assertIn(login.status_code, {200, 204, 303})
create_target = client.post(
"/admin/api/cache/targets",
json={
"name": "tier-a",
"kind": "s3",
"order_index": 1,
"capacity_songs": 10,
"public_base_url": "https://cache.example",
"path_prefix": "music",
"enabled": True,
"secrets": {
"bucket": "music-cache",
"region": "ap-shanghai",
"access_key_id": "AKIA",
"secret_access_key": "SECRET",
},
},
)
overview = client.get("/admin/api/cache/overview")
targets = client.get("/admin/api/cache/targets")
hot_songs = client.get("/admin/api/cache/hot-songs")
reconcile = client.post("/admin/api/cache/reconcile")
html_page = client.get("/admin/cache")
self.assertEqual(200, create_target.status_code)
self.assertEqual(200, overview.status_code)
self.assertEqual(200, targets.status_code)
self.assertEqual(200, hot_songs.status_code)
self.assertEqual(200, reconcile.status_code)
self.assertEqual(200, html_page.status_code)
target_payload = targets.json()["items"][0]
self.assertNotIn("secrets", target_payload)
self.assertTrue(target_payload["has_secrets"])
self.assertEqual(
["access_key_id", "bucket", "region", "secret_access_key"],
target_payload["secret_fields"],
)
self.assertEqual(2001, hot_songs.json()["items"][0]["song_id"])
self.assertEqual("Song 2001", hot_songs.json()["items"][0]["name"])
self.assertEqual("https://origin.example/2001.flac", hot_songs.json()["items"][0]["external_url"])
self.assertEqual(1, reconcile.json()["created_upload_tasks"])
self.assertIn("Cache Targets", html_page.text)
self.assertIn('id="target-form"', html_page.text)
self.assertIn('id="target-kind"', html_page.text)
self.assertIn('id="target-order-index"', html_page.text)
self.assertIn('id="target-capacity-songs"', html_page.text)
self.assertIn("Save Target", html_page.text)
self.assertIn('id="target-test-button"', html_page.text)
self.assertIn("Test Connection", html_page.text)
self.assertIn("Song Name", html_page.text)
self.assertIn("External URL", html_page.text)
self.assertNotIn("Secrets JSON", html_page.text)
self.assertIn('id="sftp-host"', html_page.text)
self.assertIn('id="sftp-remote-root"', html_page.text)
self.assertIn('id="sftp-username"', html_page.text)
self.assertIn('id="sftp-password"', html_page.text)
self.assertIn('id="s3-bucket"', html_page.text)
self.assertIn('id="s3-access-key-id"', html_page.text)
self.assertIn('id="s3-secret-access-key"', html_page.text)
def test_admin_can_test_unsaved_target_connection(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict("os.environ", self._admin_env(player_db_path, catalog_db_path), clear=False):
client = TestClient(create_app())
login = client.post(
"/admin/session/login",
data={"username": "admin", "password": "secret123"},
follow_redirects=False,
)
self.assertIn(login.status_code, {200, 204, 303})
with patch.object(
CacheService,
"test_target_connection_payload",
create=True,
return_value={
"kind": "sftp",
"ok": True,
"secret_keys": ["host", "password", "username"],
},
) as mocked_test:
response = client.post(
"/admin/api/cache/targets/test",
json={
"kind": "sftp",
"secrets": {
"host": "1.2.3.4",
"port": 22,
"username": "root",
"password": "secret",
},
},
)
self.assertEqual(200, response.status_code)
self.assertEqual("sftp", response.json()["kind"])
self.assertTrue(response.json()["ok"])
mocked_test.assert_called_once_with(
kind="sftp",
secrets={
"host": "1.2.3.4",
"port": 22,
"username": "root",
"password": "secret",
},
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,87 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
class AppUpdateRouteTests(unittest.TestCase):
def test_app_version_json_returns_server_apk_download_url(self):
with tempfile.TemporaryDirectory() as tmp:
release_dir = Path(tmp)
version_json = release_dir / "version.json"
apk_path = release_dir / "MusicFree_latest_release_universal.apk"
version_json.write_text(
json.dumps(
{
"version": "9.9.9",
"changeLog": ["server app update"],
"download": ["https://official.example/download"],
}
),
encoding="utf-8",
)
apk_path.write_bytes(b"fake-apk")
with patch.dict(
"os.environ",
{
"MUSICFREE_VERSION_JSON": str(version_json),
"MUSICFREE_APK_PATH": str(apk_path),
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS": "0",
},
clear=False,
):
client = TestClient(create_app())
response = client.get("/app/version.json")
self.assertEqual(200, response.status_code)
self.assertIn("application/json", response.headers.get("content-type", ""))
payload = response.json()
self.assertEqual("9.9.9", payload["version"])
self.assertEqual(["server app update"], payload["changeLog"])
self.assertEqual(
["http://testserver/app/MusicFree_latest_release_universal.apk"],
payload["download"],
)
def test_app_apk_route_serves_configured_apk_file(self):
with tempfile.TemporaryDirectory() as tmp:
release_dir = Path(tmp)
version_json = release_dir / "version.json"
apk_path = release_dir / "MusicFree_latest_release_universal.apk"
version_json.write_text(
json.dumps({"version": "9.9.9", "changeLog": [], "download": []}),
encoding="utf-8",
)
apk_path.write_bytes(b"fake-apk")
with patch.dict(
"os.environ",
{
"MUSICFREE_VERSION_JSON": str(version_json),
"MUSICFREE_APK_PATH": str(apk_path),
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS": "0",
},
clear=False,
):
client = TestClient(create_app())
response = client.get("/app/MusicFree_latest_release_universal.apk")
self.assertEqual(200, response.status_code)
self.assertEqual(b"fake-apk", response.content)
self.assertIn(
"application/vnd.android.package-archive",
response.headers.get("content-type", ""),
)
if __name__ == "__main__":
unittest.main()
+175
View File
@@ -0,0 +1,175 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from music_server.services.token_service import TokenService
from tests.support import auth_headers, issue_access_token
class AuthRouteTests(unittest.TestCase):
def _prepare_catalog_db(self, db_path: Path) -> None:
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,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(101, "super", "flac", 100, "object_storage", "cdn", "101.flac", None, "active", 1),
(101, "standard", "mp3", 80, "object_storage", "cdn", "101.mp3", None, "active", 0),
(102, "standard", "mp3", 90, "object_storage", "cdn", "102.mp3", None, "inactive", 1),
(103, "standard", "mp3", 90, "object_storage", "cdn", "103.mp3", None, "active", 1),
],
)
conn.commit()
conn.close()
def test_token_status_active_returns_playable_song_count(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/auth/v1/token-status",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["valid"])
self.assertEqual("active", payload["status"])
self.assertEqual(2, payload["playableSongCount"])
def test_token_status_client_id_missing_uses_body_status_not_401(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
token = issue_access_token(player_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/auth/v1/token-status",
headers=auth_headers(
player_db_path,
token=token,
include_client_id=False,
),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertFalse(payload["valid"])
self.assertEqual("client_id_missing", payload["status"])
self.assertIsNone(payload["playableSongCount"])
def test_token_status_rejects_missing_bearer(self):
client = TestClient(create_app())
response = client.get("/auth/v1/token-status")
self.assertEqual(401, response.status_code)
def test_token_status_allows_missing_bearer_when_auth_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
"MUSIC_SERVER_DISABLE_AUTH": "1",
},
clear=False,
):
client = TestClient(create_app())
response = client.get("/auth/v1/token-status")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["valid"])
self.assertEqual("active", payload["status"])
self.assertEqual("auth_disabled", payload["source"])
self.assertEqual(2, payload["playableSongCount"])
def test_token_status_active_degrades_when_catalog_track_files_table_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
sqlite3.connect(catalog_db_path).close()
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app(), raise_server_exceptions=False)
response = client.get(
"/auth/v1/token-status",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["valid"])
self.assertEqual("active", payload["status"])
self.assertIsNone(payload["playableSongCount"])
def test_auth_headers_reuses_same_token_by_default_for_same_db(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
first = auth_headers(player_db_path)
second = auth_headers(player_db_path)
self.assertEqual(first["Authorization"], second["Authorization"])
service = TokenService(str(player_db_path))
self.assertEqual(1, len(service.list_tokens(include_revoked=True)))
if __name__ == "__main__":
unittest.main()
+521
View File
@@ -0,0 +1,521 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from music_server.services.cache_service import CacheService
class CacheServiceTests(unittest.TestCase):
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table catalog_tracks (
song_id integer primary key,
platform text not null,
remote_song_id text not null,
name text not null,
singers text,
album text,
cover_url text,
duration_ms integer,
metadata_json text
)
"""
)
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
)
"""
)
track_rows = [
(1001, "kuwo", "remote-1001", "Song 1001", "Singer 1001", "Album 1001", None, None, None),
(1002, "kuwo", "remote-1002", "Song 1002", "Singer 1002", "Album 1002", None, None, None),
(1003, "kuwo", "remote-1003", "Song 1003", "Singer 1003", "Album 1003", None, None, None),
(1004, "kuwo", "remote-1004", "Song 1004", "Singer 1004", "Album 1004", None, None, None),
]
conn.executemany(
"""
insert into catalog_tracks (
song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
track_rows,
)
rows = [
(1001, "standard", "mp3", 1000, "local_fs", "library", "std/1001.mp3", None, "active", 0),
(1001, "super", "flac", 2000, "local_fs", "library", "super/1001.flac", None, "active", 1),
(1002, "standard", "mp3", 1000, "local_fs", "library", "std/1002.mp3", None, "active", 1),
(1003, "standard", "mp3", 1000, "object_storage", "main", "obj/1003.mp3", "https://origin.example/1003.mp3", "active", 1),
(1004, "standard", "mp3", 1000, "object_storage", "main", "obj/1004.mp3", "https://origin.example/1004.mp3", "active", 1),
]
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
conn.commit()
conn.close()
def test_record_stream_play_counts_each_stream_token_once(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
first = service.record_stream_play(
song_id=1001,
stream_token="stream-a",
played_at="2026-04-23T10:00:00+00:00",
)
second = service.record_stream_play(
song_id=1001,
stream_token="stream-a",
played_at="2026-04-23T10:01:00+00:00",
)
summary = service.get_heat_summary(song_id=1001)
self.assertTrue(first)
self.assertFalse(second)
self.assertEqual(1, summary["play_count_total"])
self.assertEqual(1, summary["play_count_30d"])
self.assertEqual("2026-04-23T10:00:00+00:00", summary["last_played_at"])
def test_record_stream_play_is_noop_when_cache_relay_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
cache_relay_enabled=False,
)
recorded = service.record_stream_play(
song_id=1001,
stream_token="stream-disabled",
played_at="2026-04-23T10:00:00+00:00",
)
summary = service.get_heat_summary(song_id=1001)
self.assertFalse(recorded)
self.assertEqual(0, summary["play_count_total"])
self.assertEqual(0, summary["play_count_30d"])
self.assertIsNone(summary["last_played_at"])
def test_reconcile_assigns_strict_top_n_targets_and_best_quality_source(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
target_a = service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=2,
public_base_url="https://cache-a.example",
path_prefix="music/a",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
target_b = service.create_cache_target(
name="tier-b",
kind="s3",
order_index=2,
capacity_songs=1,
public_base_url="https://cache-b.example",
path_prefix="music/b",
enabled=True,
secrets={"bucket": "music", "region": "test"},
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=50,
play_count_30d=50,
last_played_at="2026-04-23T12:00:00+00:00",
)
service.upsert_heat_summary(
song_id=1002,
play_count_total=40,
play_count_30d=40,
last_played_at="2026-04-23T11:00:00+00:00",
)
service.upsert_heat_summary(
song_id=1003,
play_count_total=30,
play_count_30d=30,
last_played_at="2026-04-23T10:00:00+00:00",
)
service.upsert_heat_summary(
song_id=1004,
play_count_total=20,
play_count_30d=20,
last_played_at="2026-04-23T09:00:00+00:00",
)
result = service.reconcile_cache_assignments(now="2026-04-23T13:00:00+00:00")
objects = service.list_cache_objects()
tasks = service.list_transfer_tasks()
self.assertEqual(3, result["desired_song_count"])
self.assertEqual(3, len(objects))
self.assertEqual(3, len(tasks))
self.assertEqual(
[
(1001, target_a["id"], "super", "super/1001.flac", "pending_upload"),
(1002, target_a["id"], "standard", "std/1002.mp3", "pending_upload"),
(1003, target_b["id"], "standard", "obj/1003.mp3", "pending_upload"),
],
[
(
item["song_id"],
item["target_id"],
item["quality_label"],
item["source_locator"],
item["status"],
)
for item in objects
],
)
def test_reconcile_marks_out_of_range_items_evictable_and_only_deletes_when_over_capacity(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
target = service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=1,
public_base_url="https://cache-a.example",
path_prefix="music",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
service.upsert_cache_object(
song_id=1002,
target_id=target["id"],
quality_label="standard",
source_locator="std/1002.mp3",
remote_key="music/1002.mp3",
public_url="https://cache-a.example/music/1002.mp3",
status="active",
last_rank=1,
uploaded_at="2026-04-20T00:00:00+00:00",
last_verified_at="2026-04-20T00:00:00+00:00",
evictable=False,
)
service.upsert_cache_object(
song_id=1004,
target_id=target["id"],
quality_label="standard",
source_locator="obj/1004.mp3",
remote_key="music/1004.mp3",
public_url="https://cache-a.example/music/1004.mp3",
status="active",
last_rank=2,
uploaded_at="2026-04-20T00:00:00+00:00",
last_verified_at="2026-04-20T00:00:00+00:00",
evictable=False,
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=10,
play_count_30d=10,
last_played_at="2026-04-23T12:00:00+00:00",
)
service.reconcile_cache_assignments(now="2026-04-23T13:00:00+00:00")
objects = service.list_cache_objects(target_id=target["id"])
tasks = service.list_transfer_tasks(task_kind="delete")
self.assertEqual(
[(1001, "pending_upload", False), (1002, "evictable", True)],
[(item["song_id"], item["status"], bool(item["evictable"])) for item in objects],
)
self.assertEqual([1004], [item["song_id"] for item in tasks])
def test_process_transfer_tasks_marks_uploaded_objects_active(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
local_file = library_root / "super" / "1001.flac"
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(b"audio-data")
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
local_library_root=str(library_root),
)
service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=1,
public_base_url="https://cache-a.example",
path_prefix="music",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=99,
play_count_30d=99,
last_played_at="2026-04-23T12:00:00+00:00",
)
service.reconcile_cache_assignments(now="2026-04-23T13:00:00+00:00")
with patch("music_server.services.cache_service.SFTPCacheTargetUploader.upload_file") as upload_file:
result = service.process_transfer_tasks(now="2026-04-23T13:05:00+00:00")
objects = service.list_cache_objects()
tasks = service.list_transfer_tasks()
self.assertEqual(1, result["uploaded"])
self.assertEqual("active", objects[0]["status"])
self.assertEqual("success", tasks[0]["status"])
upload_file.assert_called_once()
def test_reconcile_is_noop_when_cache_relay_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
cache_relay_enabled=False,
)
service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=2,
public_base_url="https://cache-a.example",
path_prefix="music/a",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=50,
play_count_30d=50,
last_played_at="2026-04-23T12:00:00+00:00",
)
result = service.reconcile_cache_assignments(now="2026-04-23T13:00:00+00:00")
self.assertEqual(0, result["desired_song_count"])
self.assertEqual(0, result["created_upload_tasks"])
self.assertEqual(0, result["created_delete_tasks"])
self.assertEqual([], service.list_cache_objects())
self.assertEqual([], service.list_transfer_tasks())
def test_process_transfer_tasks_is_noop_when_cache_relay_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
cache_relay_enabled=False,
)
target = service.create_cache_target(
name="tier-a",
kind="sftp",
order_index=1,
capacity_songs=1,
public_base_url="https://cache-a.example",
path_prefix="music",
enabled=True,
secrets={"host": "127.0.0.1", "username": "tester"},
)
service.upsert_cache_object(
song_id=1001,
target_id=target["id"],
quality_label="super",
source_locator="super/1001.flac",
remote_key="music/1001_super.flac",
public_url="https://cache-a.example/music/1001_super.flac",
status="pending_upload",
last_rank=1,
uploaded_at=None,
last_verified_at=None,
evictable=False,
)
conn = sqlite3.connect(player_db_path)
conn.execute(
"""
insert into cache_transfer_tasks (
song_id, target_id, task_kind, quality_label, source_locator, remote_key,
public_url, status, run_id, created_at, updated_at
) values (?, ?, 'upload', ?, ?, ?, ?, 'pending', null, ?, ?)
""",
(
1001,
target["id"],
"super",
"super/1001.flac",
"music/1001_super.flac",
"https://cache-a.example/music/1001_super.flac",
"2026-04-23T13:00:00+00:00",
"2026-04-23T13:00:00+00:00",
),
)
conn.commit()
conn.close()
result = service.process_transfer_tasks(now="2026-04-23T13:05:00+00:00")
tasks = service.list_transfer_tasks()
objects = service.list_cache_objects()
self.assertEqual({"uploaded": 0, "deleted": 0, "failed": 0}, result)
self.assertEqual("pending", tasks[0]["status"])
self.assertEqual("pending_upload", objects[0]["status"])
def test_list_hot_songs_includes_name_and_prefers_cache_public_url(self):
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
target = service.create_cache_target(
name="tier-a",
kind="s3",
order_index=1,
capacity_songs=10,
public_base_url="https://cache-a.example",
path_prefix="music",
enabled=True,
secrets={"bucket": "music-cache", "access_key_id": "AKIA", "secret_access_key": "SECRET"},
)
service.upsert_heat_summary(
song_id=1001,
play_count_total=20,
play_count_30d=20,
last_played_at="2026-04-23T12:00:00+00:00",
)
service.upsert_heat_summary(
song_id=1003,
play_count_total=10,
play_count_30d=10,
last_played_at="2026-04-23T11:00:00+00:00",
)
service.upsert_cache_object(
song_id=1001,
target_id=target["id"],
quality_label="super",
source_locator="super/1001.flac",
remote_key="music/1001_super.flac",
public_url="https://cache-a.example/music/1001_super.flac",
status="active",
last_rank=1,
uploaded_at="2026-04-23T12:05:00+00:00",
last_verified_at="2026-04-23T12:05:00+00:00",
evictable=False,
)
hot_songs = service.list_hot_songs(limit=10)
self.assertEqual(
[
(1001, "Song 1001", "https://cache-a.example/music/1001_super.flac"),
(1003, "Song 1003", "https://origin.example/1003.mp3"),
],
[(item["song_id"], item["name"], item["external_url"]) for item in hot_songs],
)
@patch("music_server.services.cache_service.SFTPCacheTargetUploader")
def test_test_target_connection_payload_passes_sftp_remote_root(self, uploader_class):
uploader = uploader_class.return_value
with tempfile.TemporaryDirectory() as tmpdir:
catalog_db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(catalog_db_path)
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
result = service.test_target_connection_payload(
kind="sftp",
secrets={
"host": "64.83.43.123",
"port": 22,
"username": "root",
"password": "secret",
"remote_root": "/srv/music_server_cache",
},
)
uploader_class.assert_called_once_with(
host="64.83.43.123",
port=22,
username="root",
password="secret",
private_key=None,
timeout_seconds=10,
remote_root="/srv/music_server_cache",
)
uploader.test_connection.assert_called_once_with()
self.assertEqual("sftp", result["kind"])
self.assertTrue(result["ok"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,63 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from music_server.services.cache_uploaders import S3CacheTargetUploader, SFTPCacheTargetUploader
class CacheUploaderTests(unittest.TestCase):
@patch("music_server.services.cache_uploaders.paramiko.SSHClient")
def test_sftp_uploader_upload_and_delete(self, ssh_client_class):
ssh_client = MagicMock()
sftp_client = MagicMock()
ssh_client.open_sftp.return_value = sftp_client
ssh_client_class.return_value = ssh_client
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "song.flac"
file_path.write_bytes(b"flac-data")
uploader = SFTPCacheTargetUploader(
host="127.0.0.1",
port=22,
username="tester",
password="secret",
remote_root="/srv/music_server_cache",
)
uploader.upload_file(local_path=file_path, remote_key="music/song.flac")
uploader.delete_file(remote_key="music/song.flac")
uploader.test_connection()
self.assertEqual(3, ssh_client.connect.call_count)
sftp_client.put.assert_called_once_with(str(file_path), "/srv/music_server_cache/music/song.flac")
sftp_client.remove.assert_called_once_with("/srv/music_server_cache/music/song.flac")
sftp_client.listdir.assert_called_once_with("/srv/music_server_cache")
@patch("music_server.services.cache_uploaders._build_boto3_client")
def test_s3_uploader_upload_and_delete(self, build_client):
s3_client = MagicMock()
build_client.return_value = s3_client
with tempfile.TemporaryDirectory() as tmpdir:
file_path = Path(tmpdir) / "song.flac"
file_path.write_bytes(b"flac-data")
uploader = S3CacheTargetUploader(
bucket="music-cache",
region="ap-shanghai",
endpoint_url="https://s3.example",
access_key_id="AKIA",
secret_access_key="SECRET",
)
uploader.upload_file(local_path=file_path, remote_key="music/song.flac")
uploader.delete_file(remote_key="music/song.flac")
uploader.test_connection()
s3_client.upload_file.assert_called_once()
s3_client.delete_object.assert_called_once_with(Bucket="music-cache", Key="music/song.flac")
s3_client.head_bucket.assert_called_once_with(Bucket="music-cache")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,639 @@
import ast
import importlib.util
import sqlite3
import tempfile
import unittest
from pathlib import Path
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "export_catalog_read.py"
SPEC = importlib.util.spec_from_file_location("export_catalog_read", SCRIPT_PATH)
assert SPEC is not None
assert SPEC.loader is not None
export_catalog_read = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(export_catalog_read)
class ExportCatalogReadTests(unittest.TestCase):
def test_export_script_defers_annotation_evaluation_for_host_python38(self):
module = ast.parse(SCRIPT_PATH.read_text(encoding="utf-8"))
future_imports = [
node
for node in module.body
if isinstance(node, ast.ImportFrom) and node.module == "__future__"
]
imported_names = {
alias.name
for node in future_imports
for alias in node.names
}
self.assertIn(
"annotations",
imported_names,
"export_catalog_read.py must defer annotation evaluation so host Python 3.8 "
"can execute the script during post-download catalog export.",
)
def setUp(self) -> None:
self._tmpdir = tempfile.TemporaryDirectory()
self._source_db = Path(self._tmpdir.name) / "catalogsync.db"
self._target_db = Path(self._tmpdir.name) / "catalog_read.db"
conn = sqlite3.connect(self._source_db)
conn.executescript(
"""
create table playlists (
id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
url text not null,
parse_strategy text not null default 'playlist_url',
cover_url text,
creator_name text,
play_count integer,
metadata_json text,
created_at text,
updated_at text,
collected_song_count integer
);
create table songs (
id integer primary key,
platform text not null,
remote_song_id text not null,
name text not null,
singers text,
album text,
duration_seconds integer,
metadata_json text
);
create table artists (
id integer primary key,
artist_key text not null unique,
platform text not null,
remote_artist_id text,
name text not null,
normalized_name text not null,
metadata_json text
);
create table artist_songs (
artist_id integer not null,
song_id integer not null,
discovered_at text
);
create table playlist_songs (
playlist_id integer not null,
song_id integer not null,
position integer
);
create table file_assets (
id integer primary key,
song_id integer not null,
quality_label text,
ext text,
file_size_bytes integer
);
create table storage_backends (
id integer primary key,
name text not null,
backend_type text not null
);
create table file_locations (
id integer primary key,
file_asset_id integer not null,
backend_id integer not null,
locator text,
public_url text,
download_url text,
status text,
is_primary integer
);
"""
)
conn.executemany(
"""
insert into playlists (
id, platform, remote_playlist_id, name, url, parse_strategy,
cover_url, creator_name, play_count, collected_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
1,
"netease",
"18165",
"Playlist One",
"https://example.com/p1",
"playlist_url",
"https://img/p1.jpg",
"creator-1",
123,
None,
),
(
2,
"qq",
"75",
"Toplist One",
"https://example.com/p2",
"qq_toplist",
"https://img/p2.jpg",
"creator-2",
456,
None,
),
],
)
conn.executemany(
"""
insert into songs (
id, platform, remote_song_id, name, singers, album, duration_seconds, metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(101, "qq", "s101", "Song 101", "Singer A", "Album A", 210, None),
(102, "qq", "s102", "Song 102", "Singer B", "Album B", 220, None),
(103, "qq", "s103", "Song 103", "Singer C", "Album C", 230, None),
],
)
conn.executemany(
"""
insert into playlist_songs (playlist_id, song_id, position) values (?, ?, ?)
""",
[
(1, 101, 1),
(1, 102, 2),
(2, 101, 1),
(2, 102, 2),
(2, 103, 3),
],
)
conn.execute(
"""
insert into storage_backends (id, name, backend_type)
values (?, ?, ?)
""",
(1, "nas", "alist"),
)
conn.executemany(
"""
insert into file_assets (id, song_id, quality_label, ext, file_size_bytes)
values (?, ?, ?, ?, ?)
""",
[
(1001, 101, "128k", "mp3", 1234),
(1002, 102, "128k", "mp3", 2345),
(1003, 103, "128k", "mp3", 3456),
(1004, 101, "flac", "flac", 4567),
],
)
conn.executemany(
"""
insert into file_locations (
id, file_asset_id, backend_id, locator,
public_url, download_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
2001,
1001,
1,
"music/101-128k.mp3",
"https://cdn.example/101-128k.mp3",
None,
"active",
1,
),
(
2002,
1002,
1,
"music/102-128k.mp3",
"https://cdn.example/102-128k.mp3",
None,
"inactive",
1,
),
(
2003,
1003,
1,
"music/103-128k.mp3",
None,
"https://download.example/103-128k.mp3",
"active",
1,
),
(
2004,
1004,
1,
"music/101-flac.flac",
"https://cdn.example/101-flac.flac",
None,
"active",
0,
),
],
)
conn.commit()
conn.close()
def tearDown(self) -> None:
self._tmpdir.cleanup()
def test_build_catalog_read_exports_song_and_playable_song_counts(self):
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
playlist_rows = conn.execute(
"""
select playlist_id, song_count, playable_song_count
from catalog_playlists
order by playlist_id
"""
).fetchall()
toplist_rows = conn.execute(
"""
select toplist_id, song_count, playable_song_count
from catalog_toplists
order by toplist_id
"""
).fetchall()
conn.close()
self.assertEqual([(1, 2, 1)], playlist_rows)
self.assertEqual([("qq_top_75", 3, 2)], toplist_rows)
def test_build_catalog_read_playable_song_count_deduplicates_duplicate_song_rows(self):
conn = sqlite3.connect(self._source_db)
conn.executemany(
"""
insert into playlist_songs (playlist_id, song_id, position) values (?, ?, ?)
""",
[
(1, 101, 3),
(2, 103, 4),
],
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
playlist_rows = conn.execute(
"""
select playlist_id, song_count, playable_song_count
from catalog_playlists
order by playlist_id
"""
).fetchall()
toplist_rows = conn.execute(
"""
select toplist_id, song_count, playable_song_count
from catalog_toplists
order by toplist_id
"""
).fetchall()
conn.close()
self.assertEqual([(1, 3, 1)], playlist_rows)
self.assertEqual([("qq_top_75", 4, 2)], toplist_rows)
def test_build_catalog_read_song_count_prefers_playlist_rows_then_collected_fallback(self):
conn = sqlite3.connect(self._source_db)
conn.executemany(
"""
update playlists
set collected_song_count = ?
where id = ?
""",
[
(99, 1),
(88, 2),
],
)
conn.executemany(
"""
insert into playlists (
id, platform, remote_playlist_id, name, url, parse_strategy,
cover_url, creator_name, play_count, collected_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
3,
"netease",
"18166",
"Playlist Fallback",
"https://example.com/p3",
"playlist_url",
"https://img/p3.jpg",
"creator-3",
321,
7,
),
(
4,
"qq",
"86",
"Toplist Fallback",
"https://example.com/p4",
"qq_toplist",
"https://img/p4.jpg",
"creator-4",
654,
6,
),
(
5,
"netease",
"19723756",
"Netease Official Toplist",
"https://example.com/p5",
"netease_toplist",
"https://img/p5.jpg",
"creator-5",
777,
10,
),
(
6,
"kuwo",
"489927",
"Kuwo Hot Toplist",
"https://example.com/p6",
"kuwo_toplist",
"https://img/p6.jpg",
"creator-6",
888,
11,
),
],
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
playlist_rows = conn.execute(
"""
select playlist_id, song_count
from catalog_playlists
order by playlist_id
"""
).fetchall()
toplist_rows = conn.execute(
"""
select toplist_id, song_count
from catalog_toplists
order by toplist_id
"""
).fetchall()
conn.close()
self.assertEqual([(1, 2), (3, 7)], playlist_rows)
self.assertEqual(
[
("kuwo_top_489927", 11),
("netease_top_19723756", 10),
("qq_top_75", 3),
("qq_top_86", 6),
],
toplist_rows,
)
def test_build_catalog_read_exports_playable_artists_and_tracks(self):
conn = sqlite3.connect(self._source_db)
conn.executemany(
"""
insert into artists (
id, artist_key, platform, remote_artist_id, name, normalized_name, metadata_json
) values (?, ?, ?, ?, ?, ?, ?)
""",
[
(
1,
"netease:artist-a",
"netease",
"artist-a",
"Singer A",
"singer a",
'{"avatar":"https://img/artist-a.jpg","description":"desc-a"}',
),
(
2,
"qq:artist-b",
"qq",
"artist-b",
"Singer B",
"singer b",
'{"avatar":"https://img/artist-b.jpg","description":"desc-b"}',
),
],
)
conn.executemany(
"""
insert into artist_songs (artist_id, song_id, discovered_at)
values (?, ?, ?)
""",
[
(1, 101, "2026-04-23T00:00:00+00:00"),
(1, 102, "2026-04-23T00:00:00+00:00"),
(2, 103, "2026-04-23T00:00:00+00:00"),
],
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
artist_rows = conn.execute(
"""
select artist_id, platform, remote_artist_id, name, avatar_url, description, playable_song_count
from catalog_artists
order by artist_id
"""
).fetchall()
artist_track_rows = conn.execute(
"""
select artist_id, song_id, position
from catalog_artist_tracks
order by artist_id, position
"""
).fetchall()
conn.close()
self.assertEqual(
[
(1, "netease", "artist-a", "Singer A", "https://img/artist-a.jpg", "desc-a", 1),
(2, "qq", "artist-b", "Singer B", "https://img/artist-b.jpg", "desc-b", 1),
],
artist_rows,
)
self.assertEqual([(1, 101, 1), (2, 103, 1)], artist_track_rows)
def test_build_catalog_read_artist_tracks_deduplicate_duplicate_artist_song_links(self):
conn = sqlite3.connect(self._source_db)
conn.execute(
"""
insert into artists (
id, artist_key, platform, remote_artist_id, name, normalized_name, metadata_json
) values (?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"netease:artist-a",
"netease",
"artist-a",
"Singer A",
"singer a",
'{"avatar":"https://img/artist-a.jpg","description":"desc-a"}',
),
)
conn.executemany(
"""
insert into artist_songs (artist_id, song_id, discovered_at)
values (?, ?, ?)
""",
[
(1, 101, "2026-04-23T00:00:00+00:00"),
(1, 101, "2026-04-23T01:00:00+00:00"),
(1, 103, "2026-04-23T02:00:00+00:00"),
],
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
artist_rows = conn.execute(
"""
select artist_id, playable_song_count
from catalog_artists
order by artist_id
"""
).fetchall()
artist_track_rows = conn.execute(
"""
select artist_id, song_id, position
from catalog_artist_tracks
order by artist_id, position
"""
).fetchall()
conn.close()
self.assertEqual([(1, 2)], artist_rows)
self.assertEqual([(1, 101, 1), (1, 103, 2)], artist_track_rows)
def test_build_catalog_read_artist_export_ignores_dangling_song_relations(self):
conn = sqlite3.connect(self._source_db)
conn.execute(
"""
insert into artists (
id, artist_key, platform, remote_artist_id, name, normalized_name, metadata_json
) values (?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"netease:artist-a",
"netease",
"artist-a",
"Singer A",
"singer a",
'{"avatar":"https://img/artist-a.jpg","description":"desc-a"}',
),
)
conn.executemany(
"""
insert into artist_songs (artist_id, song_id, discovered_at)
values (?, ?, ?)
""",
[
(1, 101, "2026-04-23T00:00:00+00:00"),
(1, 999, "2026-04-23T01:00:00+00:00"),
],
)
conn.execute(
"""
insert into file_assets (id, song_id, quality_label, ext, file_size_bytes)
values (?, ?, ?, ?, ?)
""",
(1005, 999, "128k", "mp3", 2222),
)
conn.execute(
"""
insert into file_locations (
id, file_asset_id, backend_id, locator,
public_url, download_url, status, is_primary
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
2005,
1005,
1,
"music/999-128k.mp3",
"https://cdn.example/999-128k.mp3",
None,
"active",
1,
),
)
conn.commit()
conn.close()
export_catalog_read.build_catalog_read(
source_db=str(self._source_db),
target_db=str(self._target_db),
)
conn = sqlite3.connect(self._target_db)
artist_rows = conn.execute(
"""
select artist_id, playable_song_count
from catalog_artists
order by artist_id
"""
).fetchall()
artist_track_rows = conn.execute(
"""
select artist_id, song_id, position
from catalog_artist_tracks
order by artist_id, position
"""
).fetchall()
conn.close()
self.assertEqual([(1, 1)], artist_rows)
self.assertEqual([(1, 101, 1)], artist_track_rows)
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
import unittest
import tempfile
from pathlib import Path
from unittest.mock import patch
from fastapi import HTTPException
from fastapi.testclient import TestClient
from music_server.app import create_app
from music_server.auth import require_bearer_token
from music_server.services.token_service import TokenService
from music_server.settings import get_settings
class HealthRouteTests(unittest.TestCase):
def test_healthz_returns_ok(self):
client = TestClient(create_app())
response = client.get("/healthz")
self.assertEqual(200, response.status_code)
self.assertEqual({"status": "ok"}, response.json())
class SettingsAndAuthTests(unittest.TestCase):
def test_get_settings_reflects_runtime_env_changes(self):
with patch.dict(
"os.environ",
{
"PUBLIC_MUSIC_ACCESS_TOKEN": "token-one",
"CATALOG_DB_PATH": "./tmp/catalog-one.db",
"PLAYER_DB_PATH": "./tmp/player-one.db",
"MUSIC_SERVER_DISABLE_AUTH": "0",
},
clear=False,
):
first = get_settings()
with patch.dict(
"os.environ",
{
"PUBLIC_MUSIC_ACCESS_TOKEN": "token-two",
"CATALOG_DB_PATH": "./tmp/catalog-two.db",
"PLAYER_DB_PATH": "./tmp/player-two.db",
"MUSIC_SERVER_DISABLE_AUTH": "1",
},
clear=False,
):
second = get_settings()
self.assertEqual("token-one", first.access_token)
self.assertEqual("./tmp/catalog-one.db", first.catalog_db_path)
self.assertEqual("./tmp/player-one.db", first.player_db_path)
self.assertEqual("token-two", second.access_token)
self.assertEqual("./tmp/catalog-two.db", second.catalog_db_path)
self.assertEqual("./tmp/player-two.db", second.player_db_path)
self.assertFalse(first.disable_auth)
self.assertTrue(second.disable_auth)
def test_get_settings_reads_cache_relay_switch(self):
with patch.dict(
"os.environ",
{"MUSIC_SERVER_CACHE_RELAY_ENABLED": "0"},
clear=False,
):
disabled = get_settings()
with patch.dict(
"os.environ",
{"MUSIC_SERVER_CACHE_RELAY_ENABLED": "1"},
clear=False,
):
enabled = get_settings()
self.assertFalse(disabled.cache_relay_enabled)
self.assertTrue(enabled.cache_relay_enabled)
def test_require_bearer_token_accepts_case_insensitive_scheme(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
service = TokenService(str(player_db_path))
issued = service.issue_token(label="health-auth")
with patch.dict(
"os.environ",
{"PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
require_bearer_token(
authorization=f" bearer {issued.plaintext_token} ",
x_music_client_id="client-case",
x_music_client_label="Case Client",
)
def test_require_bearer_token_is_bypassed_when_auth_disabled(self):
with patch.dict(
"os.environ",
{"MUSIC_SERVER_DISABLE_AUTH": "1"},
clear=False,
):
require_bearer_token(
authorization=None,
x_music_client_id=None,
)
def test_require_bearer_token_raises_specific_error_codes(self):
with self.assertRaises(HTTPException) as missing:
require_bearer_token(
authorization=None,
x_music_client_id="client-a",
)
self.assertEqual(401, missing.exception.status_code)
self.assertEqual("authorization_missing", missing.exception.detail)
with self.assertRaises(HTTPException) as invalid:
require_bearer_token(
authorization="Basic abc",
x_music_client_id="client-a",
)
self.assertEqual(401, invalid.exception.status_code)
self.assertEqual("authorization_invalid", invalid.exception.detail)
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
service = TokenService(str(player_db_path))
issued = service.issue_token(label="health-auth")
with patch.dict(
"os.environ",
{"PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
with self.assertRaises(HTTPException) as missing_client_id:
require_bearer_token(
authorization=f"Bearer {issued.plaintext_token}",
x_music_client_id=None,
)
self.assertEqual(401, missing_client_id.exception.status_code)
self.assertEqual("client_id_missing", missing_client_id.exception.detail)
with self.assertRaises(HTTPException) as wrong:
require_bearer_token(
authorization="Bearer not-exists",
x_music_client_id="client-a",
)
self.assertEqual(401, wrong.exception.status_code)
self.assertEqual("token_not_found", wrong.exception.detail)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
import unittest
from pathlib import Path
from music_server.services.local_streaming import (
RangeNotSatisfiable,
guess_audio_media_type,
parse_single_range,
)
class GuessAudioMediaTypeTests(unittest.TestCase):
def test_guess_audio_media_type_supported_extensions(self):
self.assertEqual("audio/flac", guess_audio_media_type("a.flac"))
self.assertEqual("audio/mpeg", guess_audio_media_type("a.mp3"))
self.assertEqual("audio/mp4", guess_audio_media_type(Path("a.m4a")))
self.assertEqual("audio/wav", guess_audio_media_type("a.wav"))
self.assertEqual("audio/ogg", guess_audio_media_type("a.ogg"))
self.assertEqual("audio/ape", guess_audio_media_type("a.ape"))
def test_guess_audio_media_type_falls_back_to_octet_stream(self):
self.assertEqual("application/octet-stream", guess_audio_media_type("a.bin"))
class ParseSingleRangeTests(unittest.TestCase):
def test_parse_single_range_returns_none_for_missing_header(self):
self.assertIsNone(parse_single_range(None, 10))
def test_parse_single_range_explicit_start_end(self):
self.assertEqual((2, 5), parse_single_range("bytes=2-5", 10))
def test_parse_single_range_clamps_out_of_bounds_end(self):
self.assertEqual((0, 9), parse_single_range("bytes=0-999", 10))
def test_parse_single_range_suffix(self):
self.assertEqual((6, 9), parse_single_range("bytes=-4", 10))
def test_parse_single_range_open_ended(self):
self.assertEqual((2, 9), parse_single_range("bytes=2-", 10))
def test_parse_single_range_rejects_out_of_bounds(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=10-12", 10)
def test_parse_single_range_rejects_reverse_range(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=7-2", 10)
def test_parse_single_range_rejects_multi_range(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("bytes=0-1,3-4", 10)
def test_parse_single_range_rejects_non_bytes_unit(self):
with self.assertRaises(RangeNotSatisfiable):
parse_single_range("items=0-1", 10)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,776 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from tests.support import auth_headers
class MfCatalogRouteTests(unittest.TestCase):
def _prepare_playlist_toplist_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
create table catalog_toplists (
toplist_id text primary key,
platform text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null,
group_name text not null
);
create table catalog_tracks (
song_id integer primary key,
platform text not null,
remote_song_id text not null,
name text not null,
singers text,
album text,
cover_url text,
duration_ms integer,
metadata_json text
);
create table catalog_track_files (
song_id integer not null,
quality_label text not null,
ext text not null,
file_size_bytes integer,
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
);
create table catalog_playlist_tracks (
playlist_id integer not null,
song_id integer not null,
position integer not null
);
create table catalog_toplist_tracks (
toplist_id text not null,
song_id integer not null,
position integer not null
);
create table catalog_artists (
artist_id integer primary key,
artist_key text not null unique,
platform text not null,
remote_artist_id text,
name text not null,
normalized_name text not null,
avatar_url text,
description text,
playable_song_count integer not null
);
create table catalog_artist_tracks (
artist_id integer not null,
song_id integer not null,
position integer not null
);
"""
)
conn.execute(
"""
insert into catalog_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(1, "netease", "rid-1", "playlist-1", "desc", "https://img/1.jpg", 100, 2, 1),
)
conn.execute(
"""
insert into catalog_toplists (
toplist_id, platform, name, description, cover_url, play_count, song_count, playable_song_count, group_name
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("tl-1", "netease", "toplist-1", "desc", "https://img/top.jpg", 88, 2, 1, "official"),
)
conn.executemany(
"""
insert into catalog_tracks (
song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, metadata_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
1,
"netease",
"n1",
"Playable Song",
"Singer A",
"Album A",
"https://img/song1.jpg",
200000,
"{}",
),
(
2,
"netease",
"n2",
"Blocked Song",
"Singer B",
"Album B",
"https://img/song2.jpg",
180000,
"{}",
),
],
)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
1,
"super",
"flac",
100,
"object_storage",
"cdn",
"song-1.flac",
"https://cdn/1.flac",
"active",
1,
),
(
2,
"standard",
"mp3",
90,
"object_storage",
"cdn",
"song-2.mp3",
"https://cdn/2.mp3",
"inactive",
1,
),
],
)
conn.executemany(
"""
insert into catalog_playlist_tracks (playlist_id, song_id, position) values (?, ?, ?)
""",
[(1, 1, 1), (1, 2, 2)],
)
conn.executemany(
"""
insert into catalog_toplist_tracks (toplist_id, song_id, position) values (?, ?, ?)
""",
[("tl-1", 1, 1), ("tl-1", 2, 2)],
)
conn.execute(
"""
insert into catalog_artists (
artist_id, artist_key, platform, remote_artist_id, name, normalized_name, avatar_url, description, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"netease:artist-a",
"netease",
"artist-a",
"Singer A",
"singer a",
"https://img/artist-a.jpg",
"artist-desc",
1,
),
)
conn.execute(
"""
insert into catalog_artist_tracks (artist_id, song_id, position) values (?, ?, ?)
""",
(1, 1, 1),
)
conn.commit()
conn.close()
def test_recommend_tags_returns_musicfree_shape(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(player_db_path)}, clear=False):
client = TestClient(create_app())
response = client.get(
"/mf/v1/recommend/tags",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertIn("pinned", payload)
self.assertIn("data", payload)
self.assertEqual(["all", "netease", "qq", "kuwo"], [item["id"] for item in payload["pinned"]])
self.assertEqual(
["playlist_square", "toplist"],
[item["id"] for item in payload["data"][0]["data"]],
)
def test_recommend_routes_requires_token_when_missing(self):
client = TestClient(create_app())
response = client.get("/mf/v1/recommend/tags")
self.assertEqual(401, response.status_code)
def test_recommend_routes_requires_token_when_wrong(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(player_db_path)}, clear=False):
client = TestClient(create_app())
response = client.get(
"/mf/v1/recommend/tags",
headers=auth_headers(player_db_path, token="wrong-token"),
)
self.assertEqual(401, response.status_code)
def test_recommend_routes_allow_anonymous_when_auth_disabled(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"MUSIC_SERVER_DISABLE_AUTH": "1",
},
clear=False,
):
client = TestClient(create_app())
response = client.get("/mf/v1/recommend/tags")
self.assertEqual(200, response.status_code)
def test_recommend_sheets_returns_musicfree_shape(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.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
"""
)
rows = [
(
1,
"netease",
"rid-1",
"测试歌单",
"desc",
"https://img/1.jpg",
999,
9,
5,
)
]
for idx in range(2, 21):
rows.append(
(
idx,
"netease",
f"rid-{idx}",
f"playlist-{idx}",
"desc",
f"https://img/{idx}.jpg",
200 - idx,
5,
5,
)
)
conn.executemany(
"""
insert into catalog_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/recommend/sheets?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
response_large_page = client.get(
"/mf/v1/recommend/sheets?page=1&page_size=21",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertFalse(payload["isEnd"])
self.assertEqual("catalogsync:playlist:1", payload["data"][0]["id"])
self.assertEqual("测试歌单", payload["data"][0]["title"])
self.assertEqual(9, payload["data"][0]["worksNum"])
self.assertEqual(5, payload["data"][0]["playableSongCount"])
self.assertEqual(999, payload["data"][0]["play_count"])
self.assertNotIn("playCount", payload["data"][0])
self.assertEqual(200, response_large_page.status_code)
self.assertTrue(response_large_page.json()["isEnd"])
def test_recommend_sheets_filters_by_platform_tag(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.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
"""
)
conn.executemany(
"""
insert into catalog_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(1, "netease", "n-1", "Netease List", "desc", "https://img/1.jpg", 300, 10, 10),
(2, "qq", "q-1", "QQ List", "desc", "https://img/2.jpg", 200, 10, 10),
(3, "kuwo", "k-1", "Kuwo List", "desc", "https://img/3.jpg", 100, 10, 10),
],
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/recommend/sheets?tag=qq&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["isEnd"])
self.assertEqual(["catalogsync:playlist:2"], [item["id"] for item in payload["data"]])
self.assertEqual(["QQ List"], [item["title"] for item in payload["data"]])
def test_recommend_sheets_returns_toplists_when_tag_toplist(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
conn = sqlite3.connect(db_path)
conn.execute(
"""
insert into catalog_toplists (
toplist_id, platform, name, description, cover_url, play_count, song_count, playable_song_count, group_name
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("tl-2", "netease", "toplist-2", "desc", "https://img/top2.jpg", 77, 1, 1, "official"),
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response_page_1 = client.get(
"/mf/v1/recommend/sheets?tag=toplist&page=1&page_size=1",
headers=auth_headers(player_db_path),
)
response_page_2 = client.get(
"/mf/v1/recommend/sheets?tag=toplist&page=2&page_size=1",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response_page_1.status_code)
self.assertEqual(200, response_page_2.status_code)
payload_page_1 = response_page_1.json()
payload_page_2 = response_page_2.json()
self.assertEqual(["catalogsync:toplist:tl-1"], [item["id"] for item in payload_page_1["data"]])
self.assertTrue(payload_page_1["data"][0]["id"].startswith("catalogsync:toplist:"))
self.assertFalse(payload_page_1["isEnd"])
self.assertEqual(["catalogsync:toplist:tl-2"], [item["id"] for item in payload_page_2["data"]])
self.assertTrue(payload_page_2["data"][0]["id"].startswith("catalogsync:toplist:"))
self.assertTrue(payload_page_2["isEnd"])
def test_search_songs_returns_musicfree_shape(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/search/songs?q=Playable&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertTrue(payload["isEnd"])
self.assertEqual(["catalogsync:song:1"], [item["id"] for item in payload["data"]])
def test_search_songs_includes_raw_lrc_when_local_lyrics_exist(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
library_root.mkdir()
self._prepare_playlist_toplist_catalog_db(db_path)
conn = sqlite3.connect(db_path)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"standard",
"mp3",
80,
"local_fs",
"library",
"Singer A/Playable Song.mp3",
None,
"active",
0,
),
)
conn.commit()
conn.close()
song_dir = library_root / "Singer A"
song_dir.mkdir()
(song_dir / "Playable Song.mp3").write_bytes(b"audio")
(song_dir / "Playable Song.lrc").write_text("[00:00.00]hello lyric\n", encoding="utf-8")
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"LOCAL_LIBRARY_ROOT": str(library_root),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/search/songs?q=Playable&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("[00:00.00]hello lyric\n", payload["data"][0]["rawLrc"])
def test_song_lyric_route_returns_raw_lrc_when_local_lyrics_exist(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
library_root.mkdir()
self._prepare_playlist_toplist_catalog_db(db_path)
conn = sqlite3.connect(db_path)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"standard",
"mp3",
80,
"local_fs",
"library",
"Singer A/Playable Song.mp3",
None,
"active",
0,
),
)
conn.commit()
conn.close()
song_dir = library_root / "Singer A"
song_dir.mkdir()
(song_dir / "Playable Song.mp3").write_bytes(b"audio")
(song_dir / "Playable Song.lrc").write_text("[00:00.00]hello lyric\n", encoding="utf-8")
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"LOCAL_LIBRARY_ROOT": str(library_root),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/songs/1/lyric",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("[00:00.00]hello lyric\n", payload["rawLrc"])
self.assertEqual("[00:00.00]hello lyric\n", payload["lyric"])
def test_playlist_tracks_omit_raw_lrc_when_local_library_root_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
conn = sqlite3.connect(db_path)
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"standard",
"mp3",
80,
"local_fs",
"library",
"Singer A/Playable Song.mp3",
None,
"active",
0,
),
)
conn.commit()
conn.close()
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/playlists/1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertNotIn("rawLrc", payload["musicList"][0])
def test_search_artists_and_artist_detail_routes_return_musicfree_shape(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
client = TestClient(create_app())
search_response = client.get(
"/mf/v1/search/artists?q=Singer&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
detail_response = client.get(
"/mf/v1/artists/1",
headers=auth_headers(player_db_path),
)
tracks_response = client.get(
"/mf/v1/artists/1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, search_response.status_code)
self.assertEqual(200, detail_response.status_code)
self.assertEqual(200, tracks_response.status_code)
self.assertEqual("catalogsync:artist:1", search_response.json()["data"][0]["id"])
self.assertEqual(["music"], search_response.json()["data"][0]["supportedArtistTabs"])
self.assertEqual("Singer A", detail_response.json()["name"])
self.assertEqual("catalogsync:song:1", tracks_response.json()["musicList"][0]["id"])
def test_search_sheets_returns_playlists_and_toplists(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{"CATALOG_DB_PATH": str(db_path), "PLAYER_DB_PATH": str(player_db_path)},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/search/sheets?q=1&page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(
["catalogsync:playlist:1", "catalogsync:toplist:tl-1"],
[item["id"] for item in payload["data"]],
)
def test_playlist_tracks_only_returns_playable_rows(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/playlists/1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(1, len(payload["musicList"]))
self.assertEqual("catalogsync:song:1", payload["musicList"][0]["id"])
self.assertEqual("Playable Song", payload["musicList"][0]["title"])
def test_toplists_returns_playable_song_count(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/toplists",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(1, len(payload))
self.assertEqual(1, len(payload[0]["data"]))
toplist = payload[0]["data"][0]
self.assertEqual("catalogsync:toplist:tl-1", toplist["id"])
self.assertEqual(2, toplist["worksNum"])
self.assertEqual(1, toplist["playableSongCount"])
def test_toplist_tracks_only_returns_playable_rows(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_playlist_toplist_catalog_db(db_path)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
client = TestClient(create_app())
response = client.get(
"/mf/v1/toplists/tl-1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(1, len(payload["musicList"]))
self.assertEqual("catalogsync:song:1", payload["musicList"][0]["id"])
self.assertEqual("Playable Song", payload["musicList"][0]["title"])
if __name__ == "__main__":
unittest.main()
+289
View File
@@ -0,0 +1,289 @@
import sqlite3
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from tests.support import auth_headers
class MfDetailRouteTests(unittest.TestCase):
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
);
create table catalog_tracks (
song_id integer primary key,
platform text not null,
remote_song_id text not null,
name text not null,
singers text,
album text,
cover_url text,
duration_ms integer not null,
source_meta text not null
);
create table catalog_playlist_tracks (
playlist_id integer not null,
song_id integer not null,
position integer not null,
primary key (playlist_id, song_id)
);
create table catalog_toplists (
toplist_id text primary key,
platform text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null,
group_name text not null
);
create table catalog_toplist_tracks (
toplist_id text not null,
song_id integer not null,
position integer not null,
primary key (toplist_id, song_id)
);
create table catalog_track_files (
song_id integer not null,
quality_label text not null,
ext text not null,
file_size_bytes integer,
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_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(1, "netease", "18165", "playlist-1", "desc", "https://img/p.jpg", 2000, 2, 2),
)
conn.executemany(
"""
insert into catalog_tracks (
song_id, platform, remote_song_id, name, singers, album, cover_url, duration_ms, source_meta
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
3476,
"netease",
"65800",
"song-3476",
"artist-a",
"album-a",
"https://img/s-3476.jpg",
220000,
"{}",
),
(
4001,
"qq",
"4001",
"song-4001",
"artist-b",
"album-b",
"https://img/s-4001.jpg",
180000,
"{}",
),
],
)
conn.executemany(
"""
insert into catalog_playlist_tracks (playlist_id, song_id, position)
values (?, ?, ?)
""",
[(1, 3476, 1), (1, 4001, 2)],
)
conn.execute(
"""
insert into catalog_toplists (
toplist_id, platform, name, description, cover_url, play_count, song_count, playable_song_count, group_name
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
"kuwo_top_16",
"kuwo",
"\u9177\u6211\u98d9\u5347\u699c",
"desc",
"https://img/t.jpg",
1000,
2,
2,
"\u9177\u6211",
),
)
conn.executemany(
"""
insert into catalog_toplist_tracks (toplist_id, song_id, position)
values (?, ?, ?)
""",
[("kuwo_top_16", 3476, 1), ("kuwo_top_16", 4001, 2)],
)
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",
100,
"object_storage",
"cdn",
"song-3476.flac",
"https://cdn/song-3476.flac",
"active",
1,
),
(
4001,
"standard",
"mp3",
90,
"object_storage",
"cdn",
"song-4001.mp3",
"https://cdn/song-4001.mp3",
"active",
1,
),
],
)
conn.commit()
conn.close()
@contextmanager
def _catalog_client(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",
{
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
},
clear=False,
):
yield TestClient(create_app()), player_db_path
def test_get_toplist_detail_returns_musicfree_shape(self):
with self._catalog_client() as (client, player_db_path):
response = client.get(
"/mf/v1/toplists/kuwo_top_16",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("catalogsync:toplist:kuwo_top_16", payload["id"])
self.assertEqual("catalogsync", payload["platform"])
self.assertEqual("\u9177\u6211\u98d9\u5347\u699c", payload["title"])
def test_get_toplist_tracks_pagination(self):
with self._catalog_client() as (client, player_db_path):
first_page = client.get(
"/mf/v1/toplists/kuwo_top_16/tracks?page=1&page_size=1",
headers=auth_headers(player_db_path),
)
second_page = client.get(
"/mf/v1/toplists/kuwo_top_16/tracks?page=2&page_size=1",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, first_page.status_code)
self.assertFalse(first_page.json()["isEnd"])
self.assertEqual("catalogsync:song:3476", first_page.json()["musicList"][0]["id"])
self.assertEqual(200, second_page.status_code)
self.assertTrue(second_page.json()["isEnd"])
self.assertEqual("catalogsync:song:4001", second_page.json()["musicList"][0]["id"])
def test_get_toplist_detail_returns_404_when_missing(self):
with self._catalog_client() as (client, player_db_path):
response = client.get(
"/mf/v1/toplists/missing_toplist",
headers=auth_headers(player_db_path),
)
self.assertEqual(404, response.status_code)
def test_get_toplist_tracks_returns_404_when_missing(self):
with self._catalog_client() as (client, player_db_path):
response = client.get(
"/mf/v1/toplists/missing_toplist/tracks?page=1&page_size=1",
headers=auth_headers(player_db_path),
)
self.assertEqual(404, response.status_code)
def test_get_toplist_routes_require_token(self):
with self._catalog_client() as (client, _player_db_path):
detail_response = client.get("/mf/v1/toplists/kuwo_top_16")
tracks_response = client.get(
"/mf/v1/toplists/kuwo_top_16/tracks?page=1&page_size=1"
)
self.assertEqual(401, detail_response.status_code)
self.assertEqual(401, tracks_response.status_code)
def test_legacy_playlist_tracks_and_toplists_routes_still_work(self):
with self._catalog_client() as (client, player_db_path):
playlist_tracks_response = client.get(
"/mf/v1/playlists/1/tracks?page=1&page_size=20",
headers=auth_headers(player_db_path),
)
toplists_response = client.get(
"/mf/v1/toplists",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, playlist_tracks_response.status_code)
playlist_payload = playlist_tracks_response.json()
self.assertTrue(playlist_payload["musicList"])
self.assertEqual("catalogsync:song:3476", playlist_payload["musicList"][0]["id"])
self.assertEqual(200, toplists_response.status_code)
toplists_payload = toplists_response.json()
self.assertEqual("\u9177\u6211", toplists_payload[0]["title"])
if __name__ == "__main__":
unittest.main()
+578
View File
@@ -0,0 +1,578 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from music_server.services.cache_service import CacheService
from tests.support import auth_headers
class MfMediaRouteTests(unittest.TestCase):
def _prepare_catalog_db(
self,
db_path: Path,
*,
backend_type: str = "object_storage",
backend_name: str = "main-s3",
locator: str = "music/netease/test.flac",
public_url: str | None = "https://cdn.example/test.flac",
file_size_bytes: int = 42345678,
) -> None:
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",
file_size_bytes,
backend_type,
backend_name,
locator,
public_url,
"active",
1,
),
)
conn.commit()
conn.close()
def _prepare_active_cache(
self,
*,
player_db_path: Path,
catalog_db_path: Path,
song_id: int = 3476,
cache_url: str = "https://cache.example/test.flac",
status: str = "active",
) -> None:
service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(catalog_db_path),
secret_encryption_key="test-secret",
)
target = service.create_cache_target(
name="edge-cache",
kind="s3",
order_index=1,
capacity_songs=10,
public_base_url="https://cache.example",
path_prefix="songs",
enabled=True,
secrets={"bucket": "music", "region": "test"},
)
service.upsert_cache_object(
song_id=song_id,
target_id=target["id"],
quality_label="super",
source_locator="cache/test.flac",
remote_key="songs/test.flac",
public_url=cache_url,
status=status,
last_rank=1,
uploaded_at="2026-04-23T00:00:00+00:00",
last_verified_at="2026-04-23T00:00:00+00:00",
evictable=False,
)
def test_media_stream_extension_route_registers_before_generic_route(self):
app = create_app()
stream_paths = [
route.path
for route in app.routes
if getattr(route, "path", "").startswith("/mf/v1/media/stream/")
]
self.assertEqual(
[
"/mf/v1/media/stream/{token}.{ext}",
"/mf/v1/media/stream/{token}",
],
stream_paths,
)
def test_media_resolve_returns_selected_source_and_signed_stream_url(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())
response = client.post(
"/mf/v1/media/resolve",
headers=auth_headers(player_db_path),
json={"song_id": "catalogsync:song:3476", "quality": "super"},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("object_storage", payload["selected_source"]["kind"])
self.assertEqual("main-s3", payload["selected_source"]["backend"])
self.assertEqual("super", payload["selected_source"]["quality"])
self.assertEqual("flac", payload["selected_source"]["ext"])
self.assertEqual(42345678, payload["selected_source"]["size_bytes"])
self.assertIn("/mf/v1/media/stream/", payload["stream"]["url"])
self.assertTrue(payload["stream"]["url"].endswith(".flac"))
def test_media_stream_redirects_to_public_url(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"]
stream_response = client.get(
stream_url,
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_resolve_prefers_active_cache_object(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)
self._prepare_active_cache(player_db_path=player_db_path, catalog_db_path=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",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
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)
self.assertEqual("cache", resolve_response.json()["selected_source"]["kind"])
with patch("music_server.routes.mf_media._is_cache_url_reachable", return_value=True):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cache.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_resolve_falls_back_when_cache_object_is_not_active(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)
self._prepare_active_cache(
player_db_path=player_db_path,
catalog_db_path=db_path,
status="failed",
)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
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)
self.assertEqual("object_storage", resolve_response.json()["selected_source"]["kind"])
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_resolve_and_stream_ignore_cache_when_cache_relay_disabled(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)
self._prepare_active_cache(player_db_path=player_db_path, catalog_db_path=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",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
"MUSIC_SERVER_CACHE_RELAY_ENABLED": "0",
},
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)
self.assertEqual("object_storage", resolve_response.json()["selected_source"]["kind"])
with patch("music_server.routes.mf_media._is_cache_url_reachable", return_value=True):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
heat_service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(db_path),
secret_encryption_key="test-secret",
)
summary = heat_service.get_heat_summary(song_id=3476)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
self.assertEqual(0, summary["play_count_total"])
self.assertEqual(0, summary["play_count_30d"])
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"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
self._prepare_active_cache(player_db_path=player_db_path, catalog_db_path=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",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
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_cache_url_reachable", return_value=False):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.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"
player_db_path = Path(tmpdir) / "player.db"
self._prepare_catalog_db(db_path)
self._prepare_active_cache(player_db_path=player_db_path, catalog_db_path=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",
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
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_cache_url_reachable", return_value=False):
stream_response = client.get(
resolve_response.json()["stream"]["url"],
follow_redirects=False,
)
self.assertEqual(307, stream_response.status_code)
self.assertEqual(
"https://cdn.example/test.flac",
stream_response.headers.get("location"),
)
def test_media_stream_serves_local_file_when_public_url_missing(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
local_file = library_root / "music" / "netease" / "test.flac"
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(b"flac-bytes")
self._prepare_catalog_db(
db_path,
backend_type="local_fs",
backend_name="default-local",
locator="music/netease/test.flac",
public_url=None,
file_size_bytes=10,
)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"LOCAL_LIBRARY_ROOT": str(library_root),
},
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"]
stream_response = client.get(stream_url)
self.assertEqual(200, stream_response.status_code)
self.assertEqual(b"flac-bytes", stream_response.content)
self.assertEqual("bytes", stream_response.headers.get("accept-ranges"))
self.assertEqual("10", stream_response.headers.get("content-length"))
self.assertEqual("audio/flac", stream_response.headers.get("content-type"))
def test_media_stream_local_single_range_returns_206_and_partial_content(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
local_file = library_root / "music" / "netease" / "test.flac"
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(b"0123456789")
self._prepare_catalog_db(
db_path,
backend_type="local_fs",
backend_name="default-local",
locator="music/netease/test.flac",
public_url=None,
file_size_bytes=10,
)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"LOCAL_LIBRARY_ROOT": str(library_root),
},
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"]
stream_response = client.get(stream_url, headers={"Range": "bytes=2-5"})
self.assertEqual(206, stream_response.status_code)
self.assertEqual(b"2345", stream_response.content)
self.assertEqual("bytes", stream_response.headers.get("accept-ranges"))
self.assertEqual("bytes 2-5/10", stream_response.headers.get("content-range"))
self.assertEqual("4", stream_response.headers.get("content-length"))
self.assertEqual("audio/flac", stream_response.headers.get("content-type"))
def test_media_stream_local_invalid_range_returns_416(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
local_file = library_root / "music" / "netease" / "test.flac"
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(b"0123456789")
self._prepare_catalog_db(
db_path,
backend_type="local_fs",
backend_name="default-local",
locator="music/netease/test.flac",
public_url=None,
file_size_bytes=10,
)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"LOCAL_LIBRARY_ROOT": str(library_root),
},
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"]
stream_response = client.get(stream_url, headers={"Range": "bytes=99-100"})
self.assertEqual(416, stream_response.status_code)
self.assertEqual("bytes", stream_response.headers.get("accept-ranges"))
self.assertEqual("bytes */10", stream_response.headers.get("content-range"))
def test_media_stream_counts_heat_once_for_repeated_stream_requests(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db"
player_db_path = Path(tmpdir) / "player.db"
library_root = Path(tmpdir) / "library"
local_file = library_root / "music" / "netease" / "test.flac"
local_file.parent.mkdir(parents=True, exist_ok=True)
local_file.write_bytes(b"0123456789")
self._prepare_catalog_db(
db_path,
backend_type="local_fs",
backend_name="default-local",
locator="music/netease/test.flac",
public_url=None,
file_size_bytes=10,
)
with patch.dict(
"os.environ",
{
"CATALOG_DB_PATH": str(db_path),
"PLAYER_DB_PATH": str(player_db_path),
"PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token",
"LOCAL_LIBRARY_ROOT": str(library_root),
"MUSIC_SERVER_SECRET_ENCRYPTION_KEY": "test-secret",
},
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"]
full_response = client.get(stream_url)
range_response = client.get(stream_url, headers={"Range": "bytes=2-5"})
heat_service = CacheService(
player_db_path=str(player_db_path),
catalog_db_path=str(db_path),
secret_encryption_key="test-secret",
)
summary = heat_service.get_heat_summary(song_id=3476)
self.assertEqual(200, full_response.status_code)
self.assertEqual(206, range_response.status_code)
self.assertEqual(1, summary["play_count_total"])
self.assertEqual(1, summary["play_count_30d"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,82 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEPLOY_DOC = REPO_ROOT / "docs" / "nas-docker-deployment.md"
COMPOSE_FILE = REPO_ROOT / "docker-compose.nas.yml"
DEPLOY_ENTRY = REPO_ROOT / "deploy-music-server.ps1"
DEPLOY_PS = REPO_ROOT / "scripts" / "deploy_to_nas.ps1"
DEPLOY_PY = REPO_ROOT / "scripts" / "deploy_to_nas.py"
DEPLOY_TEMPLATE = REPO_ROOT / "scripts" / "templates" / "deploy_and_restart.sh"
ENV_EXAMPLE = REPO_ROOT / "config" / "music_server.env.example"
def load_deploy_module():
spec = importlib.util.spec_from_file_location("music_server_deploy_to_nas", DEPLOY_PY)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class NasDeployLayoutTests(unittest.TestCase):
def test_compose_uses_runtime_dirs_outside_repo_checkout(self):
compose_text = COMPOSE_FILE.read_text(encoding="utf-8")
self.assertIn("- ../config/music_server.env", compose_text)
self.assertIn("- ../data:/app/data", compose_text)
self.assertNotIn("- ./config/music_server.env", compose_text)
self.assertNotIn("- ./data:/app/data", compose_text)
def test_docs_and_scripts_reference_standard_music_cloud_layout(self):
deploy_text = DEPLOY_DOC.read_text(encoding="utf-8")
self.assertTrue(DEPLOY_ENTRY.exists(), f"missing deploy entry: {DEPLOY_ENTRY}")
self.assertTrue(DEPLOY_PS.exists(), f"missing deploy powershell: {DEPLOY_PS}")
self.assertTrue(DEPLOY_PY.exists(), f"missing deploy python: {DEPLOY_PY}")
self.assertTrue(DEPLOY_TEMPLATE.exists(), f"missing deploy template: {DEPLOY_TEMPLATE}")
self.assertIn("/volume4/Music_Cloud/Music_Server/app", deploy_text)
self.assertIn("/volume4/Music_Cloud/Music_Server/config/music_server.env", deploy_text)
self.assertIn("/volume4/Music_Cloud/Music_Server/data/catalog_read.db", deploy_text)
self.assertIn("/volume4/Music_Cloud/Music_Server/bin/deploy_and_restart.sh", deploy_text)
self.assertIn("deploy-music-server.ps1", deploy_text)
def test_deploy_helper_skips_generated_archives_and_cache_dirs(self):
module = load_deploy_module()
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "src").mkdir()
(root / "__pycache__").mkdir()
(root / ".git").mkdir()
(root / "src" / "app.py").write_text("print('ok')\n", encoding="utf-8")
(root / "__pycache__" / "ignored.pyc").write_bytes(b"123")
(root / ".git" / "config").write_text("[core]\n", encoding="utf-8")
(root / "music_server_deploy.tar").write_bytes(b"tar")
(root / "music_server_deploy.zip").write_bytes(b"zip")
actual = sorted(path.relative_to(root).as_posix() for path in module.iter_local_files(root))
self.assertEqual(actual, ["src/app.py"])
def test_deploy_template_removes_conflicting_fixed_name_music_server_container(self):
template_text = DEPLOY_TEMPLATE.read_text(encoding="utf-8")
self.assertIn(
"docker rm -f music-server >/dev/null 2>&1 || true",
template_text,
)
def test_env_example_and_docs_do_not_reference_wireguard_settings(self):
deploy_text = DEPLOY_DOC.read_text(encoding="utf-8")
env_example_text = ENV_EXAMPLE.read_text(encoding="utf-8")
self.assertNotIn("WIREGUARD_", deploy_text)
self.assertNotIn("WIREGUARD_", env_example_text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,45 @@
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DEPLOY_DOC = REPO_ROOT / "docs" / "nas-docker-deployment.md"
SPEC_DOC = (
REPO_ROOT
/ "docs"
/ "superpowers"
/ "specs"
/ "2026-04-19-music-cloud-public-music-service-design.md"
)
LEGACY_APP = "/volume4/Music_Server/app"
TARGET_ROOT = "/volume4/Music_Cloud/Music_Server"
TARGET_APP = "/volume4/Music_Cloud/Music_Server/app"
REQUIRED_SPEC_ITEMS = (
"/volume4/Music_Cloud",
"/volume4/Music_Cloud/catalogsync",
"/volume4/Music_Cloud/catalogsync/data/catalogsync.db",
"/volume4/Music_Cloud/library",
"/volume4/Music_Cloud/playlists",
"catalogsync serve",
)
class NasDeploymentPathTests(unittest.TestCase):
def test_docs_use_music_cloud_host_root(self):
deploy_text = DEPLOY_DOC.read_text(encoding="utf-8")
spec_text = SPEC_DOC.read_text(encoding="utf-8")
self.assertIn(TARGET_ROOT, deploy_text)
self.assertIn(TARGET_APP, deploy_text)
self.assertNotIn(LEGACY_APP, deploy_text)
self.assertIn(TARGET_ROOT, spec_text)
self.assertIn(TARGET_APP, spec_text)
self.assertNotIn(LEGACY_APP, spec_text)
for item in REQUIRED_SPEC_ITEMS:
self.assertIn(item, spec_text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,145 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from tests.support import auth_headers
class PlayerHistoryRouteTests(unittest.TestCase):
def _prepare_player_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.executescript(
"""
create table play_history (
id integer primary key autoincrement,
track_id integer not null,
played_at text not null,
progress_seconds integer not null
);
create table favorite_playlists (
playlist_id integer primary key,
added_at text not null
);
"""
)
conn.commit()
conn.close()
def _prepare_catalog_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table catalog_playlists (
playlist_id integer primary key,
platform text not null,
remote_playlist_id text not null,
name text not null,
description text,
cover_url text,
play_count integer not null,
song_count integer not null,
playable_song_count integer not null
)
"""
)
conn.execute(
"""
insert into catalog_playlists (
playlist_id, platform, remote_playlist_id, name, description, cover_url, play_count, song_count, playable_song_count
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
"netease",
"18165",
"测试歌单",
"desc",
"https://img/p.jpg",
1000,
1,
1,
),
)
conn.commit()
conn.close()
def test_history_home_and_list_routes(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_player_db(player_db_path)
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app())
post_response = client.post(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
json={"track_id": 3476, "progress_seconds": 12},
)
home_response = client.get(
"/player/v1/home",
headers=auth_headers(player_db_path),
)
history_response = client.get(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
)
self.assertEqual(201, post_response.status_code)
self.assertEqual(200, home_response.status_code)
self.assertEqual(200, history_response.status_code)
self.assertEqual(3476, history_response.json()["items"][0]["track_id"])
def test_record_history_rejects_invalid_payload(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_player_db(player_db_path)
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
client = TestClient(create_app(), raise_server_exceptions=False)
missing_track_id = client.post(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
json={"progress_seconds": 12},
)
invalid_track_id = client.post(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
json={"track_id": "abc", "progress_seconds": 12},
)
invalid_progress = client.post(
"/player/v1/me/history",
headers=auth_headers(player_db_path),
json={"track_id": 3476, "progress_seconds": "xx"},
)
self.assertEqual(400, missing_track_id.status_code)
self.assertEqual(400, invalid_track_id.status_code)
self.assertEqual(400, invalid_progress.status_code)
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
import sqlite3
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from music_server.app import create_app
from tests.support import auth_headers
class PlayerRouteTests(unittest.TestCase):
def _prepare_player_db(self, db_path: Path) -> None:
conn = sqlite3.connect(db_path)
conn.execute(
"""
create table favorite_tracks (
track_id integer primary key,
added_at text not null
)
"""
)
conn.commit()
conn.close()
def test_favorite_track_put_then_list(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
self._prepare_player_db(db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(db_path),
},
clear=False,
):
client = TestClient(create_app())
put_response = client.put(
"/player/v1/me/favorites/tracks/3476",
headers=auth_headers(db_path),
)
get_response = client.get(
"/player/v1/me/favorites/tracks",
headers=auth_headers(db_path),
)
self.assertEqual(204, put_response.status_code)
self.assertEqual(200, get_response.status_code)
self.assertEqual({"items": [{"track_id": 3476}]}, get_response.json())
def test_favorite_track_routes_work_with_fresh_empty_db(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(db_path),
},
clear=False,
):
client = TestClient(create_app())
put_response = client.put(
"/player/v1/me/favorites/tracks/8888",
headers=auth_headers(db_path),
)
get_response = client.get(
"/player/v1/me/favorites/tracks",
headers=auth_headers(db_path),
)
self.assertEqual(204, put_response.status_code)
self.assertEqual(200, get_response.status_code)
self.assertEqual({"items": [{"track_id": 8888}]}, get_response.json())
if __name__ == "__main__":
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
import unittest
from fastapi.testclient import TestClient
from music_server.app import create_app
class PluginRouteTests(unittest.TestCase):
def test_plugin_js_route_returns_importable_plugin_asset(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server.js")
self.assertEqual(200, response.status_code)
self.assertIn("javascript", response.headers.get("content-type", ""))
body = response.text
self.assertIn('platform: "Music_Server"', body)
self.assertIn('srcUrl: "http://testserver/plugins/music_server.js"', body)
self.assertNotIn("__MUSIC_SERVER_PLUGIN_SRC_URL__", body)
self.assertEqual(
"no-store, no-cache, must-revalidate, max-age=0",
response.headers.get("cache-control"),
)
def test_lan_plugin_js_route_returns_importable_plugin_asset(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, response.status_code)
self.assertIn("javascript", response.headers.get("content-type", ""))
body = response.text
self.assertIn('platform: "Music_Server_LAN"', body)
self.assertIn('srcUrl: "http://testserver/plugins/music_server_lan.js"', body)
self.assertNotIn("__MUSIC_SERVER_PLUGIN_SRC_URL__", body)
self.assertEqual(
"no-store, no-cache, must-revalidate, max-age=0",
response.headers.get("cache-control"),
)
def test_private_plugin_asset_exposes_artist_and_sheet_support(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server.js")
self.assertEqual(200, response.status_code)
body = response.text
self.assertIn('supportedSearchType: ["music", "artist", "sheet"]', body)
self.assertIn("/mf/v1/search/artists", body)
self.assertIn("/mf/v1/search/sheets", body)
self.assertIn("function getArtistWorks(", body)
def test_lan_plugin_asset_exposes_artist_and_sheet_support(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, response.status_code)
body = response.text
self.assertIn('supportedSearchType: ["music", "artist", "sheet"]', body)
self.assertIn("/mf/v1/search/artists", body)
self.assertIn("/mf/v1/search/sheets", body)
self.assertIn("function getArtistWorks(", body)
def test_plugin_assets_preserve_raw_lrc_field(self):
client = TestClient(create_app())
private_response = client.get("/plugins/music_server.js")
lan_response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, private_response.status_code)
self.assertEqual(200, lan_response.status_code)
self.assertIn("rawLrc", private_response.text)
self.assertIn("rawLrc", lan_response.text)
def test_plugin_assets_use_play_count_field_only(self):
client = TestClient(create_app())
private_response = client.get("/plugins/music_server.js")
lan_response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, private_response.status_code)
self.assertEqual(200, lan_response.status_code)
self.assertIn("item.play_count", private_response.text)
self.assertIn("result.play_count", private_response.text)
self.assertNotIn("item.playCount", private_response.text)
self.assertNotIn("result.playCount", private_response.text)
self.assertIn("item.play_count", lan_response.text)
self.assertIn("result.play_count", lan_response.text)
self.assertNotIn("item.playCount", lan_response.text)
self.assertNotIn("result.playCount", lan_response.text)
def test_plugin_assets_expose_get_lyric_method(self):
client = TestClient(create_app())
private_response = client.get("/plugins/music_server.js")
lan_response = client.get("/plugins/music_server_lan.js")
self.assertEqual(200, private_response.status_code)
self.assertEqual(200, lan_response.status_code)
self.assertIn("async function getLyric(", private_response.text)
self.assertIn('"/mf/v1/songs/" + songId + "/lyric"', private_response.text)
self.assertIn("getLyric: getLyric", private_response.text)
self.assertIn("async function getLyric(", lan_response.text)
self.assertIn('"/mf/v1/songs/" + songId + "/lyric"', lan_response.text)
self.assertIn("getLyric: getLyric", lan_response.text)
def test_plugin_manifest_route_returns_plugin_url(self):
client = TestClient(create_app())
response = client.get("/plugins/music_server.json")
self.assertEqual(200, response.status_code)
self.assertIn("application/json", response.headers.get("content-type", ""))
payload = response.json()
self.assertEqual(2, len(payload["plugins"]))
self.assertEqual(
[
{
"name": "Music_Server",
"url": "http://testserver/plugins/music_server.js",
},
{
"name": "Music_Server LAN",
"url": "http://testserver/plugins/music_server_lan.js",
},
],
payload["plugins"],
)
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
import io
import runpy
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from music_server.services.token_service import TokenService
class TokenCliTests(unittest.TestCase):
def test_issue_token_prints_plaintext_token_and_expiry(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(db_path)}, clear=False):
with patch("sys.argv", ["issue_token", "--days", "90", "--label", "iphone16"]):
buffer = io.StringIO()
with redirect_stdout(buffer):
runpy.run_module("music_server.tools.issue_token", run_name="__main__")
output = buffer.getvalue()
self.assertIn("token_id=", output)
self.assertIn("token=", output)
self.assertIn("expires_at=", output)
def test_unbind_and_revoke_commands_mutate_service_state(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="android")
service.authenticate(issued.plaintext_token, "client-a", "Pixel")
with patch.dict("os.environ", {"PLAYER_DB_PATH": str(db_path)}, clear=False):
with patch("sys.argv", ["unbind_token", "--token-id", issued.token_id]):
runpy.run_module("music_server.tools.unbind_token", run_name="__main__")
with patch(
"sys.argv",
["revoke_token", "--token-id", issued.token_id, "--reason", "replaced"],
):
runpy.run_module("music_server.tools.revoke_token", run_name="__main__")
listed = service.list_tokens(include_revoked=True)
self.assertIsNone(listed[0]["bound_client_id"])
self.assertEqual("replaced", listed[0]["revoked_reason"])
+283
View File
@@ -0,0 +1,283 @@
import sqlite3
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import music_server.services.token_service as token_service_module
from music_server.services.token_service import TokenService
class _RaceInjectingConnection:
def __init__(self, conn: sqlite3.Connection, race_state: dict[str, bool]) -> None:
self._conn = conn
self._race_state = race_state
self._injecting = False
def execute(self, sql: str, parameters=()):
normalized_sql = " ".join(sql.lower().split())
is_bind_update = (
normalized_sql.startswith("update access_tokens")
and "set bound_client_id = ?, bound_client_label = ?, bound_at = ?, last_seen_at = ?" in normalized_sql
and "where token_id = ? and bound_client_id is null" in normalized_sql
)
if is_bind_update and not self._injecting and not self._race_state["done"]:
token_id = parameters[4]
self._injecting = True
try:
self._conn.execute(
"""
update access_tokens
set bound_client_id = ?, bound_client_label = ?, bound_at = ?, last_seen_at = ?
where token_id = ? and bound_client_id is null
""",
(
"racer-client",
"Race Winner",
"2000-01-01T00:00:00+00:00",
"2000-01-01T00:00:00+00:00",
token_id,
),
)
self._race_state["done"] = True
finally:
self._injecting = False
if parameters is None:
return self._conn.execute(sql)
return self._conn.execute(sql, parameters)
def __getattr__(self, name: str):
return getattr(self._conn, name)
class _RevokeBeforeBindConnection:
def __init__(self, conn: sqlite3.Connection, race_state: dict[str, bool]) -> None:
self._conn = conn
self._race_state = race_state
self._injecting = False
def execute(self, sql: str, parameters=()):
normalized_sql = " ".join(sql.lower().split())
is_bind_update = (
normalized_sql.startswith("update access_tokens")
and "set bound_client_id = ?, bound_client_label = ?, bound_at = ?, last_seen_at = ?" in normalized_sql
and "where token_id = ? and bound_client_id is null" in normalized_sql
)
if is_bind_update and not self._injecting and not self._race_state["done"]:
token_id = parameters[4]
self._injecting = True
try:
self._conn.execute(
"""
update access_tokens
set revoked_at = ?, revoked_reason = ?
where token_id = ?
""",
(
"2000-01-01T00:00:00+00:00",
"revoked-during-bind-race",
token_id,
),
)
self._race_state["done"] = True
finally:
self._injecting = False
if parameters is None:
return self._conn.execute(sql)
return self._conn.execute(sql, parameters)
def __getattr__(self, name: str):
return getattr(self._conn, name)
class TokenServiceTests(unittest.TestCase):
def _build_racing_connect(self):
race_state = {"done": False}
real_connect = token_service_module.connect_sqlite
def racing_connect(db_path: str):
return _RaceInjectingConnection(real_connect(db_path), race_state)
return racing_connect
def _build_revoke_before_bind_connect(self):
race_state = {"done": False}
real_connect = token_service_module.connect_sqlite
def racing_connect(db_path: str):
return _RevokeBeforeBindConnection(real_connect(db_path), race_state)
return racing_connect
def test_issue_token_persists_hash_and_listable_metadata(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="iphone16")
listed = service.list_tokens(include_revoked=True)
self.assertTrue(issued.plaintext_token.startswith("msv1_"))
self.assertEqual("iphone16", listed[0]["label"])
self.assertEqual(issued.token_id, listed[0]["token_id"])
self.assertIsNone(listed[0]["bound_client_id"])
conn = sqlite3.connect(db_path)
row = conn.execute(
"select token_hash, expires_at from access_tokens where token_id = ?",
(issued.token_id,),
).fetchone()
conn.close()
self.assertIsNotNone(row)
self.assertNotEqual(issued.plaintext_token, row[0])
def test_authenticate_binds_first_client_and_reuses_same_client(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="ipad")
first = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice iPad",
)
second = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice iPad",
)
third = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-b",
client_label="Other Device",
)
self.assertTrue(first.valid)
self.assertTrue(second.valid)
self.assertEqual("token_bound_to_other_client", third.error_code)
def test_unbind_and_revoke_change_future_auth_outcome(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="android")
service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Pixel",
)
service.unbind_token(issued.token_id)
rebound = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-b",
client_label="New Pixel",
)
service.revoke_token(issued.token_id, reason="replaced")
revoked = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-b",
client_label="New Pixel",
)
self.assertTrue(rebound.valid)
self.assertEqual("token_revoked", revoked.error_code)
def test_authenticate_returns_bound_other_when_first_bind_loses_race(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="race")
with patch(
"music_server.services.token_service.connect_sqlite",
side_effect=self._build_racing_connect(),
):
result = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice Phone",
)
self.assertFalse(result.valid)
self.assertEqual("token_bound_to_other_client", result.error_code)
self.assertEqual("racer-client", result.bound_client_id)
def test_authenticate_compares_expiration_by_datetime_not_string(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="offset-expiry")
future_utc = datetime.now(timezone.utc) + timedelta(minutes=5)
future_with_negative_offset = future_utc.astimezone(
timezone(timedelta(hours=-12))
).isoformat()
conn = sqlite3.connect(db_path)
conn.execute(
"update access_tokens set expires_at = ? where token_id = ?",
(future_with_negative_offset, issued.token_id),
)
conn.commit()
conn.close()
result = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Offset Device",
)
self.assertTrue(result.valid)
self.assertIsNone(result.error_code)
def test_status_uses_final_binding_state_when_first_bind_loses_race(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="status-race")
with patch(
"music_server.services.token_service.connect_sqlite",
side_effect=self._build_racing_connect(),
):
payload = service.status(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice Phone",
)
self.assertFalse(payload["valid"])
self.assertEqual("token_bound_to_other_client", payload["status"])
self.assertTrue(payload["bound"])
self.assertFalse(payload["isCurrentClientBound"])
self.assertEqual("Race Winner", payload["boundClientLabel"])
def test_authenticate_does_not_pass_when_token_revoked_before_first_bind(self):
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "player.db"
service = TokenService(str(db_path))
issued = service.issue_token(days=90, label="revoke-race")
with patch(
"music_server.services.token_service.connect_sqlite",
side_effect=self._build_revoke_before_bind_connect(),
):
result = service.authenticate(
plaintext_token=issued.plaintext_token,
client_id="client-a",
client_label="Alice Phone",
)
self.assertFalse(result.valid)
self.assertEqual("token_revoked", result.error_code)
if __name__ == "__main__":
unittest.main()