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()