Initial import: Music_Server, MusicFree, catalog-sync
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user