522 lines
21 KiB
Python
522 lines
21 KiB
Python
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()
|