Files
musicdl-catalog-sync-suite/catalog-sync/tests/catalogsync/test_ops_api.py
T

2423 lines
94 KiB
Python

import json
import tempfile
import io
import time
import unittest
import warnings
import zipfile
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
class OperationsApiTests(unittest.TestCase):
def _build_client(self) -> tuple[TestClient, Path, Path]:
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.web import create_app
tmpdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(tmpdir.cleanup)
root = Path(tmpdir.name)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text("ROOT_DIR=/music\nDOWNLOAD_SOURCES=qq\n", encoding="utf-8")
initialize_database(db_path).close()
app = create_app(db_path=db_path, env_path=env_path)
client = TestClient(app)
self.addCleanup(client.close)
return client, db_path, env_path
def test_create_app_start_runner_emits_no_deprecation_warning(self):
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.web import create_app
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
root = Path(tmpdir)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text("ROOT_DIR=/music\nDOWNLOAD_SOURCES=qq\n", encoding="utf-8")
initialize_database(db_path).close()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", DeprecationWarning)
create_app(
db_path=db_path,
env_path=env_path,
start_runner=True,
runner_sleep_seconds=0.01,
)
deprecations = [warning for warning in caught if issubclass(warning.category, DeprecationWarning)]
self.assertEqual([], deprecations)
def test_create_app_initializes_resolver_stats_side_db(self):
from musicdl.catalogsync.ops.web import create_app
from musicdl.catalogsync.resolver_stats import default_resolver_stats_db_path
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
root = Path(tmpdir)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text("ROOT_DIR=/music\nDOWNLOAD_SOURCES=qq\n", encoding="utf-8")
create_app(db_path=db_path, env_path=env_path)
resolver_stats_db_path = default_resolver_stats_db_path(db_path)
self.assertTrue(resolver_stats_db_path.exists())
def test_events_stream_interval_is_tuned_for_more_realtime_snapshots(self):
from musicdl.catalogsync.ops import web
self.assertLessEqual(web.LIVE_DASHBOARD_SNAPSHOT_INTERVAL_SECONDS, 0.5)
def _seed_playlist(
self,
db_path: Path,
*,
platform: str,
pool_kind: str,
remote_id: str,
name: str,
play_count: int | None = None,
collected_song_count: int | None = None,
) -> int:
from musicdl.catalogsync.models import PlaylistCandidate
from musicdl.catalogsync.repository import CatalogRepository
repo = CatalogRepository(db_path)
playlist_id = repo.upsert_playlist(
PlaylistCandidate(
platform=platform,
pool_kind=pool_kind,
remote_id=remote_id,
name=name,
url=f"https://example.invalid/{platform}/{remote_id}",
play_count=play_count,
collected_song_count=collected_song_count,
)
)
pool_id = repo.upsert_playlist_pool(
platform=platform,
pool_kind=pool_kind,
external_id=f"{pool_kind}:{remote_id}",
name=f"{pool_kind}-{platform}",
url=f"https://example.invalid/pool/{pool_kind}/{remote_id}",
)
repo.link_pool_playlist(pool_id, playlist_id)
return playlist_id
def _seed_song(
self,
db_path: Path,
*,
platform: str,
remote_id: str,
name: str,
singers: str = "Singer A",
metadata: dict | None = None,
) -> int:
from musicdl.catalogsync.models import CatalogSong
from musicdl.catalogsync.repository import CatalogRepository
repo = CatalogRepository(db_path)
return repo.upsert_song(
CatalogSong(
platform=platform,
remote_song_id=remote_id,
name=name,
singers=singers,
ext="mp3",
file_size_bytes=128,
quality_label="standard",
metadata=metadata or {},
)
)
def _link_playlist_song(self, db_path: Path, *, playlist_id: int, song_id: int, position: int) -> None:
from musicdl.catalogsync.repository import CatalogRepository
repo = CatalogRepository(db_path)
repo.link_playlist_song(playlist_id, song_id, position)
def _mark_local_downloaded(self, db_path: Path, *, song_id: int, relative_path: str) -> None:
from musicdl.catalogsync.repository import CatalogRepository
repo = CatalogRepository(db_path)
backend_id = repo.ensure_local_backend(
Path(db_path).parent / "library",
name="default-local",
is_default=True,
)
repo.record_local_file(
song_id=song_id,
backend_id=backend_id,
relative_path=relative_path,
file_size_bytes=128,
ext="mp3",
quality_label="standard",
)
def _mark_remote_uploaded(
self,
db_path: Path,
*,
song_id: int,
relative_path: str,
public_url: str,
download_url: str | None = None,
) -> None:
from musicdl.catalogsync.repository import CatalogRepository
repo = CatalogRepository(db_path)
local_backend_id = repo.ensure_local_backend(
Path(db_path).parent / "library",
name="default-local",
is_default=True,
)
asset_id = repo.record_local_file(
song_id=song_id,
backend_id=local_backend_id,
relative_path=relative_path,
file_size_bytes=128,
ext="mp3",
quality_label="standard",
)
remote_backend_id = repo.upsert_object_storage_backend(
name="catalog-cloud",
container_name="music-bucket",
endpoint="https://s3.example.invalid",
region=None,
base_prefix="catalogsync",
credential_env_prefix="CATALOGSYNC_TEST",
public_base_url="https://cdn.example.invalid",
)
repo.record_remote_file(
file_asset_id=asset_id,
backend_id=remote_backend_id,
container_name="music-bucket",
locator=relative_path,
public_url=public_url,
download_url=download_url,
)
def test_pages_and_jobs_endpoint_return_200(self):
client, _, _ = self._build_client()
dashboard_response = client.get("/dashboard")
self.assertEqual(200, dashboard_response.status_code)
self.assertIn("data-sse-url", dashboard_response.text)
jobs_page_response = client.get("/jobs")
self.assertEqual(200, jobs_page_response.status_code)
self.assertNotIn("data-sse-url", jobs_page_response.text)
for path in (
"/playlists",
"/songs",
"/logs",
"/config",
):
response = client.get(path)
self.assertEqual(200, response.status_code)
jobs = client.get("/api/jobs")
self.assertEqual(200, jobs.status_code)
payload = jobs.json()
self.assertIn("items", payload)
self.assertIsInstance(payload["items"], list)
invalid_limit = client.get("/api/jobs?limit=0")
self.assertEqual(422, invalid_limit.status_code)
def test_create_job_get_job_and_create_command(self):
client, _, _ = self._build_client()
create_response = client.post(
"/api/jobs",
json={
"job_type": "catalog_sync",
"requested_by": "unittest",
"sources": ["qq", "netease"],
},
)
self.assertEqual(201, create_response.status_code)
created = create_response.json()
self.assertIn("job", created)
job_id = int(created["job"]["id"])
get_response = client.get(f"/api/jobs/{job_id}")
self.assertEqual(200, get_response.status_code)
fetched = get_response.json()
self.assertEqual(job_id, int(fetched["job"]["id"]))
self.assertEqual("catalog_sync", fetched["job"]["job_type"])
self.assertEqual(["qq", "netease"], fetched["job"]["sources"])
command_response = client.post(
f"/api/jobs/{job_id}/commands",
json={"command_type": "pause", "payload": {"reason": "unit-test"}},
)
self.assertEqual(201, command_response.status_code)
command_payload = command_response.json()
self.assertEqual(job_id, int(command_payload["job_id"]))
self.assertIn("command_id", command_payload)
invalid_command_response = client.post(
f"/api/jobs/{job_id}/commands",
json={"command_type": "invalid-command"},
)
self.assertEqual(422, invalid_command_response.status_code)
retry_without_target_response = client.post(
f"/api/jobs/{job_id}/commands",
json={"command_type": "retry_item"},
)
self.assertEqual(422, retry_without_target_response.status_code)
dedupe_response = client.post(
"/api/jobs",
json={
"job_type": "collect_only",
"requested_by": "unittest",
"sources": ["qq", "qq", "netease"],
"download_sources": ["qq", "qq", "netease"],
},
)
self.assertEqual(201, dedupe_response.status_code)
deduped_job = dedupe_response.json()["job"]
self.assertEqual(["qq", "netease"], deduped_job["sources"])
self.assertEqual(["qq", "netease"], deduped_job["download_sources"])
def test_job_detail_page_exposes_job_command_entry(self):
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
job_id = repo.create_job(job_type="catalog_sync", config_snapshot={})
response = client.get(f"/jobs/{job_id}")
self.assertEqual(200, response.status_code)
self.assertIn(f"/api/jobs/{job_id}/commands", response.text)
self.assertIn('name="command_type"', response.text)
self.assertIn('value="cancel"', response.text)
self.assertIn('value="pause"', response.text)
def test_job_detail_and_dashboard_snapshot_expose_worker_and_download_stats(self):
from musicdl.catalogsync.ops.models import StageStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
job_id = repo.create_job(
job_type="catalog_sync",
config_snapshot={},
)
stage_id = repo.create_stage(
job_run_id=job_id,
stage_type="download",
seq_no=1,
status=StageStatus.RUNNING,
)
item_id = repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key="song:101",
song_id=101,
payload={"row": {"id": 101, "platform": "qq", "name": "Song 101"}},
)
repo.claim_item(item_id=item_id, worker_name="download-1")
repo.update_worker_state(
"download-1",
current_song_id=101,
current_display_text="Song 101",
last_progress_text="downloading",
)
detail_response = client.get(f"/api/jobs/{job_id}")
self.assertEqual(200, detail_response.status_code)
detail_payload = detail_response.json()
self.assertIn("workers", detail_payload)
self.assertIn("running_items", detail_payload)
self.assertIn("download_stats", detail_payload)
stream_response = client.get("/api/events/stream?once=true")
self.assertEqual(200, stream_response.status_code)
data_line = next(
line for line in stream_response.text.splitlines() if line.startswith("data: ")
)
snapshot_payload = json.loads(data_line.removeprefix("data: "))
self.assertIn("workers", snapshot_payload)
self.assertIn("running_items", snapshot_payload)
self.assertIn("download_stats", snapshot_payload)
self.assertIn("playlist_sources", snapshot_payload)
self.assertIn("active_job", snapshot_payload)
def test_dashboard_exposes_resolver_and_downloader_workers_during_download_stage(self):
from musicdl.catalogsync.ops.models import JobStatus, StageStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.RUNNING,
)
stage_id = repo.create_stage(
job_run_id=job_id,
stage_type="download",
seq_no=1,
status=StageStatus.RUNNING,
)
item_a = repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key="song:201",
song_id=201,
payload={"row": {"id": 201, "platform": "qq", "name": "Song A / Singer A"}},
)
item_b = repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key="song:202",
song_id=202,
payload={"row": {"id": 202, "platform": "qq", "name": "Song B / Singer B"}},
)
repo.claim_item(item_id=item_a, worker_name="resolve-1")
repo.update_worker_state(
worker_name="resolve-1",
current_job_item_id=item_a,
status="running",
current_song_id=201,
current_display_text="Song A / Singer A",
last_progress_text="resolving source qq (1/6)",
)
repo.claim_item(item_id=item_b, worker_name="resolve-2")
repo.update_worker_state(
worker_name="download-1",
current_job_item_id=item_b,
status="running",
current_song_id=202,
current_display_text="Song B / Singer B",
last_progress_text="12.00MB/48.00MB",
downloaded_bytes=12 * 1024 * 1024,
total_bytes=48 * 1024 * 1024,
speed_bytes_per_sec=3 * 1024 * 1024,
progress_percent=25,
)
response = client.get("/api/dashboard?include_task_rows=false")
self.assertEqual(200, response.status_code)
payload = response.json()
worker_names = [worker["worker_name"] for worker in payload["workers"]]
self.assertIn("resolve-1", worker_names)
self.assertIn("download-1", worker_names)
self.assertEqual(3 * 1024 * 1024, payload["transfer_stats"]["download_speed_bytes_per_sec"])
def test_dashboard_page_renders_task_tree_shell_without_detail_tables(self):
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
doing_job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.RUNNING,
)
done_job_id = repo.create_job(
job_type="sync_only",
config_snapshot={},
status=JobStatus.COMPLETED,
)
response = client.get("/dashboard")
self.assertEqual(200, response.status_code)
html = response.text
self.assertIn('data-task-tree-root="doing"', html)
self.assertIn('data-task-tree-root="done"', html)
self.assertIn(f'data-task-node="{doing_job_id}"', html)
self.assertIn(f'data-task-toggle="{doing_job_id}"', html)
self.assertIn(f'data-task-node="{done_job_id}"', html)
self.assertIn(f'data-task-toggle="{done_job_id}"', html)
self.assertIn("data-task-command-toggle", html)
self.assertIn("data-task-command-cancel", html)
self.assertIn("data-task-meta-inline", html)
self.assertNotIn("data-task-subtitle", html)
self.assertNotIn("data-playlist-toggle", html)
self.assertNotIn("<h3>Summary</h3>", html)
self.assertNotIn("<h3>Stages</h3>", html)
self.assertNotIn("<h3>Workers</h3>", html)
self.assertNotIn("<h3>Running Items</h3>", html)
def test_dashboard_page_orders_done_tree_by_latest_finished_at(self):
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
older_job_id = repo.create_job(
job_type="sync_only",
config_snapshot={},
status=JobStatus.COMPLETED,
)
newer_job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.COMPLETED,
)
with repo._connection() as conn:
conn.execute(
"UPDATE job_runs SET ended_at = ? WHERE id = ?",
("2026-04-18 09:00:00", older_job_id),
)
conn.execute(
"UPDATE job_runs SET ended_at = ? WHERE id = ?",
("2026-04-18 12:00:00", newer_job_id),
)
response = client.get("/dashboard")
self.assertEqual(200, response.status_code)
html = response.text
newer_index = html.index(f'data-task-node="{newer_job_id}"')
older_index = html.index(f'data-task-node="{older_job_id}"')
self.assertLess(newer_index, older_index)
def test_dashboard_page_limits_done_tree_to_recent_ten(self):
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
created_ids: list[int] = []
for index in range(1, 13):
job_id = repo.create_job(
job_type="sync_only",
config_snapshot={},
status=JobStatus.COMPLETED,
)
created_ids.append(job_id)
with repo._connection() as conn:
conn.execute(
"UPDATE job_runs SET ended_at = ? WHERE id = ?",
(f"2026-04-18 {index:02d}:00:00", job_id),
)
response = client.get("/dashboard")
self.assertEqual(200, response.status_code)
html = response.text
self.assertEqual(10, html.count('data-task-node="'))
self.assertNotIn(f'data-task-node="{created_ids[0]}"', html)
self.assertNotIn(f'data-task-node="{created_ids[1]}"', html)
self.assertIn(f'data-task-node="{created_ids[-1]}"', html)
def test_dashboard_page_renders_local_duplicate_maintenance_controls(self):
client, _, _ = self._build_client()
response = client.get("/dashboard")
self.assertEqual(200, response.status_code)
html = response.text
self.assertIn('data-maintenance-panel="local-duplicates"', html)
self.assertIn('data-maintenance-action="scan"', html)
self.assertIn('data-maintenance-action="dedupe"', html)
self.assertIn("data-maintenance-status", html)
self.assertIn("data-maintenance-result", html)
def test_config_env_revision_list_and_apply_flow(self):
client, _, _ = self._build_client()
first_content = "ROOT_DIR=/music-a\nDOWNLOAD_SOURCES=qq,netease\n"
second_content = "ROOT_DIR=/music-b\nDOWNLOAD_SOURCES=kuwo\n"
put_first = client.put(
"/api/config/env",
json={"content": first_content, "note": "first"},
)
self.assertEqual(200, put_first.status_code)
first_revision_id = int(put_first.json()["revision"]["id"])
put_second = client.put(
"/api/config/env",
json={"content": second_content, "note": "second"},
)
self.assertEqual(200, put_second.status_code)
second_revision_id = int(put_second.json()["revision"]["id"])
self.assertNotEqual(first_revision_id, second_revision_id)
revisions_response = client.get("/api/config/revisions")
self.assertEqual(200, revisions_response.status_code)
revisions = revisions_response.json()["items"]
revision_ids = {int(item["id"]) for item in revisions}
self.assertIn(first_revision_id, revision_ids)
self.assertIn(second_revision_id, revision_ids)
apply_response = client.post(f"/api/config/revisions/{first_revision_id}/apply")
self.assertEqual(200, apply_response.status_code)
env_response = client.get("/api/config/env")
self.assertEqual(200, env_response.status_code)
env_payload = env_response.json()
self.assertEqual(first_content, env_payload["content"])
self.assertEqual("/music-a", env_payload["parsed"]["ROOT_DIR"])
apply_unknown = client.post("/api/config/revisions/999999/apply")
self.assertEqual(404, apply_unknown.status_code)
def test_events_stream_returns_sse_content_type(self):
client, _, _ = self._build_client()
response = client.get("/api/events/stream?once=true")
self.assertEqual(200, response.status_code)
self.assertIn("text/event-stream", response.headers.get("content-type", ""))
self.assertIn("event: snapshot", response.text)
self.assertIn("data:", response.text)
def test_events_stream_snapshot_omits_task_rows(self):
client, _, _ = self._build_client()
response = client.get("/api/events/stream?once=true")
self.assertEqual(200, response.status_code)
data_line = next(
line for line in response.text.splitlines() if line.startswith("data: ")
)
payload = json.loads(data_line.removeprefix("data: "))
self.assertNotIn("task_rows", payload)
def test_api_playlists_mark_wanted_and_filter_wanted_only(self):
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="wanted-api-1",
name="Wanted API Playlist",
)
mark_response = client.post(
"/api/playlists/mark-wanted",
json={"playlist_ids": [playlist_id], "marked_by": "api-test"},
)
self.assertEqual(200, mark_response.status_code)
wanted_response = client.get("/api/playlists?wanted_only=1")
self.assertEqual(200, wanted_response.status_code)
wanted_payload = wanted_response.json()
self.assertEqual(1, wanted_payload["total_count"])
self.assertEqual(playlist_id, wanted_payload["items"][0]["id"])
self.assertEqual(1, wanted_payload["items"][0]["is_wanted"])
self.assertEqual("api-test", wanted_payload["items"][0]["marked_by"])
def test_api_playlists_unmark_wanted(self):
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="wanted-api-2",
name="Wanted API Playlist 2",
)
mark_response = client.post(
"/api/playlists/mark-wanted",
json={"playlist_ids": [playlist_id], "marked_by": "api-test"},
)
self.assertEqual(200, mark_response.status_code)
unmark_response = client.post(
"/api/playlists/unmark-wanted",
json={"playlist_ids": [playlist_id]},
)
self.assertEqual(200, unmark_response.status_code)
wanted_response = client.get("/api/playlists?wanted_only=1")
self.assertEqual(200, wanted_response.status_code)
wanted_payload = wanted_response.json()
self.assertEqual(0, wanted_payload["total_count"])
self.assertEqual([], wanted_payload["items"])
def test_api_playlists_download_creates_download_only_job_with_playlist_scope(self):
client, db_path, _ = self._build_client()
playlist_a = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="download-api-a",
name="Download API A",
)
playlist_b = self._seed_playlist(
db_path,
platform="netease",
pool_kind="playlist_square",
remote_id="download-api-b",
name="Download API B",
)
song_a = self._seed_song(
db_path,
platform="qq",
remote_id="download-api-song-a",
name="Download API Song A",
)
song_b = self._seed_song(
db_path,
platform="netease",
remote_id="download-api-song-b",
name="Download API Song B",
)
self._link_playlist_song(db_path, playlist_id=playlist_a, song_id=song_a, position=1)
self._link_playlist_song(db_path, playlist_id=playlist_b, song_id=song_b, position=1)
response = client.post(
"/api/playlists/download",
json={
"playlist_ids": [playlist_a, str(playlist_b), playlist_a, -1, 0],
"requested_by": "api-test",
},
)
self.assertEqual(201, response.status_code)
payload = response.json()["job"]
self.assertEqual("download_only", payload["job_type"])
self.assertEqual([playlist_a, playlist_b], payload["playlist_scope"]["playlist_ids"])
def test_api_playlists_download_adaptive_routes_mixed_states_to_two_jobs(self):
client, db_path, _ = self._build_client()
ready_for_download_playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="download-adaptive-ready",
name="Download Adaptive Ready",
)
unsynced_playlist_id = self._seed_playlist(
db_path,
platform="netease",
pool_kind="playlist_square",
remote_id="download-adaptive-unsynced",
name="Download Adaptive Unsynced",
)
ready_song_id = self._seed_song(
db_path,
platform="qq",
remote_id="download-adaptive-song-ready",
name="Download Adaptive Song Ready",
)
self._link_playlist_song(
db_path,
playlist_id=ready_for_download_playlist_id,
song_id=ready_song_id,
position=1,
)
response = client.post(
"/api/playlists/download",
json={
"playlist_ids": [ready_for_download_playlist_id, unsynced_playlist_id],
"requested_by": "api-test",
},
)
self.assertEqual(201, response.status_code)
payload = response.json()
self.assertEqual(
[ready_for_download_playlist_id],
payload["download_job"]["playlist_scope"]["playlist_ids"],
)
self.assertEqual(
[unsynced_playlist_id],
payload["sync_download_job"]["playlist_scope"]["playlist_ids"],
)
self.assertIsNone(payload.get("job"))
def test_api_playlists_download_returns_404_when_any_playlist_id_is_missing(self):
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="download-missing-known",
name="Download Missing Known",
)
song_id = self._seed_song(
db_path,
platform="qq",
remote_id="download-missing-song",
name="Download Missing Song",
)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_id, position=1)
response = client.post(
"/api/playlists/download",
json={"playlist_ids": [playlist_id, 999999]},
)
self.assertEqual(404, response.status_code)
payload = response.json()
self.assertEqual([999999], payload["detail"]["missing_playlist_ids"])
def test_api_playlists_sync_download_creates_job_with_playlist_scope(self):
client, db_path, _ = self._build_client()
playlist_a = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="sync-download-api-a",
name="Sync Download API A",
)
playlist_b = self._seed_playlist(
db_path,
platform="qq",
pool_kind="playlist_square",
remote_id="sync-download-api-b",
name="Sync Download API B",
)
response = client.post(
"/api/playlists/sync-download",
json={"playlist_ids": [playlist_b, playlist_a]},
)
self.assertEqual(201, response.status_code)
payload = response.json()["job"]
self.assertEqual("sync_download", payload["job_type"])
self.assertEqual([playlist_b, playlist_a], payload["playlist_scope"]["playlist_ids"])
def test_api_playlists_sync_creates_sync_only_job_with_scope(self):
client, db_path, _ = self._build_client()
playlist_a = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="sync-api-a",
name="Sync API A",
)
playlist_b = self._seed_playlist(
db_path,
platform="netease",
pool_kind="playlist_square",
remote_id="sync-api-b",
name="Sync API B",
)
response = client.post(
"/api/playlists/sync",
json={"playlist_ids": [playlist_b, playlist_a]},
)
self.assertEqual(201, response.status_code)
payload = response.json()["job"]
self.assertEqual("sync_only", payload["job_type"])
self.assertEqual([playlist_b, playlist_a], payload["playlist_scope"]["playlist_ids"])
def test_api_playlists_export_routes_selected_playlists_by_state(self):
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.web import create_app
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
root = Path(tmpdir)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text(
f"ROOT_DIR={root.as_posix()}\nDOWNLOAD_SOURCES=qq,kuwo\n",
encoding="utf-8",
)
initialize_database(db_path, default_library_root=root / "library").close()
downloaded_playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="export-downloaded",
name="Export Downloaded Playlist",
)
downloaded_song_id = self._seed_song(
db_path,
platform="qq",
remote_id="export-downloaded-song-1",
name="Export Downloaded Song 1",
)
self._link_playlist_song(
db_path,
playlist_id=downloaded_playlist_id,
song_id=downloaded_song_id,
position=1,
)
self._mark_local_downloaded(
db_path,
song_id=downloaded_song_id,
relative_path="qq/Singer A/export-downloaded-song-1.mp3",
)
download_playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="export-not-downloaded",
name="Export Not Downloaded Playlist",
)
download_song_id = self._seed_song(
db_path,
platform="qq",
remote_id="export-not-downloaded-song-1",
name="Export Not Downloaded Song 1",
)
self._link_playlist_song(
db_path,
playlist_id=download_playlist_id,
song_id=download_song_id,
position=1,
)
sync_download_playlist_id = self._seed_playlist(
db_path,
platform="netease",
pool_kind="playlist_square",
remote_id="export-unsynced",
name="Export Unsynced Playlist",
)
app = create_app(db_path=db_path, env_path=env_path)
client = TestClient(app)
self.addCleanup(client.close)
response = client.post(
"/api/playlists/export",
json={
"playlist_ids": [
sync_download_playlist_id,
downloaded_playlist_id,
download_playlist_id,
],
"requested_by": "api-test",
},
)
playlist_dir = root / "playlists" / f"Export Downloaded Playlist_{downloaded_playlist_id}"
playlist_dir_exists = playlist_dir.exists()
playlist_yaml_exists = (playlist_dir / "playlist.yaml").exists()
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual([downloaded_playlist_id], payload["exported_playlist_ids"])
self.assertEqual(1, payload["exported_count"])
self.assertEqual(
[download_playlist_id],
payload["download_job"]["playlist_scope"]["playlist_ids"],
)
self.assertEqual(
[sync_download_playlist_id],
payload["sync_download_job"]["playlist_scope"]["playlist_ids"],
)
self.assertTrue(playlist_dir_exists)
self.assertTrue(playlist_yaml_exists)
def test_api_playlist_export_zip_returns_zip_for_downloaded_playlist(self):
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.web import create_app
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
root = Path(tmpdir)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text(
f"ROOT_DIR={root.as_posix()}\nDOWNLOAD_SOURCES=qq\n",
encoding="utf-8",
)
initialize_database(db_path, default_library_root=root / "library").close()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="playlist-export-zip-ready",
name="Playlist Export Zip Ready",
)
song_id = self._seed_song(
db_path,
platform="qq",
remote_id="playlist-export-zip-song-1",
name="Playlist Export Zip Song 1",
)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_id, position=1)
self._mark_local_downloaded(
db_path,
song_id=song_id,
relative_path="qq/Singer A/playlist-export-zip-song-1.mp3",
)
playlist_dir = root / "playlists" / f"Playlist Export Zip Ready_{playlist_id}"
covers_dir = playlist_dir / "covers"
covers_dir.mkdir(parents=True, exist_ok=True)
(covers_dir / "playlist-cover.jpg").write_bytes(b"fake-cover")
app = create_app(db_path=db_path, env_path=env_path)
client = TestClient(app)
self.addCleanup(client.close)
response = client.get(f"/api/playlists/{playlist_id}/export.zip")
self.assertEqual(200, response.status_code)
self.assertIn("application/zip", response.headers.get("content-type", ""))
self.assertIn(".zip", response.headers.get("content-disposition", ""))
with zipfile.ZipFile(io.BytesIO(response.content), "r") as archive:
members = set(archive.namelist())
self.assertTrue(any(name.endswith("/playlist.yaml") for name in members))
self.assertTrue(any(name.endswith("/.playlist_meta.json") for name in members))
self.assertTrue(any(name.endswith("/covers/playlist-cover.jpg") for name in members))
def test_api_playlist_export_zip_returns_409_payload_for_unsynced_playlist(self):
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="playlist-export-zip-unsynced",
name="Playlist Export Zip Unsynced",
)
response = client.get(f"/api/playlists/{playlist_id}/export.zip")
self.assertEqual(409, response.status_code)
payload = response.json()
self.assertEqual("unsynced", payload["state_code"])
self.assertEqual(playlist_id, payload["playlist_id"])
self.assertIn("message", payload)
def test_api_export_zip_returns_download_url_and_bundle_downloads_when_all_ready(self):
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.web import create_app
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
root = Path(tmpdir)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text(
f"ROOT_DIR={root.as_posix()}\nDOWNLOAD_SOURCES=qq\n",
encoding="utf-8",
)
initialize_database(db_path, default_library_root=root / "library").close()
playlist_a = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="export-zip-all-ready-a",
name="Export Zip All Ready A",
)
playlist_b = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="export-zip-all-ready-b",
name="Export Zip All Ready B",
)
song_a = self._seed_song(
db_path,
platform="qq",
remote_id="export-zip-all-ready-song-a",
name="Export Zip All Ready Song A",
)
song_b = self._seed_song(
db_path,
platform="qq",
remote_id="export-zip-all-ready-song-b",
name="Export Zip All Ready Song B",
)
self._link_playlist_song(db_path, playlist_id=playlist_a, song_id=song_a, position=1)
self._link_playlist_song(db_path, playlist_id=playlist_b, song_id=song_b, position=1)
self._mark_local_downloaded(
db_path,
song_id=song_a,
relative_path="qq/Singer A/export-zip-all-ready-song-a.mp3",
)
self._mark_local_downloaded(
db_path,
song_id=song_b,
relative_path="qq/Singer A/export-zip-all-ready-song-b.mp3",
)
app = create_app(db_path=db_path, env_path=env_path)
client = TestClient(app)
self.addCleanup(client.close)
prepare_response = client.post(
"/api/playlists/export-zip",
json={"playlist_ids": [playlist_a, playlist_b]},
)
prepare_payload = prepare_response.json()
download_url = prepare_payload["download_url"]
download_response = client.get(download_url)
self.assertEqual(200, prepare_response.status_code)
self.assertEqual("ready", prepare_payload["status"])
self.assertEqual([playlist_a, playlist_b], prepare_payload["playlist_ids"])
self.assertTrue(str(download_url).startswith("/api/exports/bundles/"))
self.assertEqual(200, download_response.status_code)
self.assertIn("application/zip", download_response.headers.get("content-type", ""))
with zipfile.ZipFile(io.BytesIO(download_response.content), "r") as archive:
members = set(archive.namelist())
self.assertTrue(any(name.startswith("playlists/") for name in members))
self.assertTrue(any(name.endswith("/playlist.yaml") for name in members))
def test_api_export_zip_returns_queued_when_selection_contains_unsynced_playlist(self):
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.web import create_app
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
root = Path(tmpdir)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text(
f"ROOT_DIR={root.as_posix()}\nDOWNLOAD_SOURCES=qq\n",
encoding="utf-8",
)
initialize_database(db_path, default_library_root=root / "library").close()
ready_playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="export-zip-queued-ready",
name="Export Zip Queued Ready",
)
ready_song_id = self._seed_song(
db_path,
platform="qq",
remote_id="export-zip-queued-song-ready",
name="Export Zip Queued Song Ready",
)
self._link_playlist_song(db_path, playlist_id=ready_playlist_id, song_id=ready_song_id, position=1)
self._mark_local_downloaded(
db_path,
song_id=ready_song_id,
relative_path="qq/Singer A/export-zip-queued-song-ready.mp3",
)
unsynced_playlist_id = self._seed_playlist(
db_path,
platform="netease",
pool_kind="playlist_square",
remote_id="export-zip-queued-unsynced",
name="Export Zip Queued Unsynced",
)
app = create_app(db_path=db_path, env_path=env_path)
client = TestClient(app)
self.addCleanup(client.close)
response = client.post(
"/api/playlists/export-zip",
json={"playlist_ids": [ready_playlist_id, unsynced_playlist_id]},
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("queued", payload["status"])
self.assertEqual([ready_playlist_id], payload["ready_playlist_ids"])
self.assertEqual([unsynced_playlist_id], payload["blocked_playlist_ids"])
self.assertIsNotNone(payload["sync_download_job"])
self.assertEqual(
[unsynced_playlist_id],
payload["sync_download_job"]["playlist_scope"]["playlist_ids"],
)
def test_api_export_zip_returns_404_when_any_playlist_id_is_missing(self):
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="export-zip-missing-known",
name="Export Zip Missing Known",
)
song_id = self._seed_song(
db_path,
platform="qq",
remote_id="export-zip-missing-song",
name="Export Zip Missing Song",
)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_id, position=1)
self._mark_local_downloaded(
db_path,
song_id=song_id,
relative_path="qq/Singer A/export-zip-missing-song.mp3",
)
response = client.post(
"/api/playlists/export-zip",
json={"playlist_ids": [playlist_id, 999999]},
)
self.assertEqual(404, response.status_code)
payload = response.json()
self.assertEqual([999999], payload["detail"]["missing_playlist_ids"])
def test_api_export_bundle_download_returns_404_for_missing_bundle(self):
client, _, _ = self._build_client()
response = client.get("/api/exports/bundles/non-existent-bundle.zip")
self.assertEqual(404, response.status_code)
def test_api_dashboard_returns_task_center_rows_with_lane_fields(self):
from musicdl.catalogsync.ops.models import ItemStatus, JobStatus, StageStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
running_job = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.RUNNING,
)
queued_job = repo.create_job(
job_type="catalog_sync",
config_snapshot={},
status=JobStatus.QUEUED,
)
stage_id = repo.create_stage(
job_run_id=running_job,
stage_type="download",
seq_no=1,
status=StageStatus.RUNNING,
)
repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key="song:done",
song_id=1,
status=ItemStatus.SUCCEEDED,
)
running_item = repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key="song:running",
song_id=2,
status=ItemStatus.PENDING,
payload={"row": {"id": 2, "name": "Song 2"}},
)
repo.claim_item(item_id=running_item, worker_name="download-1")
repo.update_worker_state(
"download-1",
status="running",
current_job_item_id=running_item,
current_song_id=2,
current_display_text="Song 2",
downloaded_bytes=5 * 1024 * 1024,
total_bytes=10 * 1024 * 1024,
speed_bytes_per_sec=2 * 1024 * 1024,
progress_percent=50.0,
)
response = client.get("/api/dashboard")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertIn("task_rows", payload)
by_id = {int(row["id"]): row for row in payload["task_rows"]}
self.assertEqual("download", by_id[running_job]["lane_type"])
self.assertEqual("queued #1", by_id[queued_job]["queue_label"])
self.assertEqual("2.0 MB/s", by_id[running_job]["speed_text"])
self.assertIn("queued_download_jobs", payload["summary"])
self.assertEqual(1, payload["summary"]["queued_download_jobs"])
def test_dashboard_transfer_stats_exposes_download_speed_and_upload_placeholder(self):
from musicdl.catalogsync.ops.models import ItemStatus, JobStatus, StageStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.RUNNING,
)
stage_id = repo.create_stage(
job_run_id=job_id,
stage_type="download",
seq_no=1,
status=StageStatus.RUNNING,
)
item_id = repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key="song:bandwidth",
song_id=9,
status=ItemStatus.PENDING,
payload={"row": {"id": 9, "name": "Bandwidth Song"}},
)
repo.claim_item(item_id=item_id, worker_name="download-bandwidth-1")
repo.update_worker_state(
"download-bandwidth-1",
status="running",
current_job_item_id=item_id,
current_song_id=9,
current_display_text="Bandwidth Song",
downloaded_bytes=4 * 1024 * 1024,
total_bytes=8 * 1024 * 1024,
speed_bytes_per_sec=2 * 1024 * 1024,
progress_percent=50.0,
)
api_response = client.get("/api/dashboard")
self.assertEqual(200, api_response.status_code)
payload = api_response.json()
self.assertEqual("2.0 MB/s", payload["transfer_stats"]["download_speed_text"])
self.assertEqual("-", payload["transfer_stats"]["upload_speed_text"])
page_response = client.get("/dashboard")
self.assertEqual(200, page_response.status_code)
html = page_response.text
self.assertIn("Task Center", html)
self.assertIn("Down 2.0 MB/s", html)
self.assertIn("Up -", html)
def test_dashboard_transfer_stats_reset_when_worker_claims_new_item(self):
from musicdl.catalogsync.ops.models import ItemStatus, JobStatus, StageStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.RUNNING,
)
stage_id = repo.create_stage(
job_run_id=job_id,
stage_type="download",
seq_no=1,
status=StageStatus.RUNNING,
)
first_item_id = repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key="song:first-speed",
song_id=19,
status=ItemStatus.PENDING,
payload={"row": {"id": 19, "name": "First Speed Song"}},
)
repo.claim_item(item_id=first_item_id, worker_name="download-1")
repo.update_worker_state(
"download-1",
status="running",
current_job_item_id=first_item_id,
current_song_id=19,
current_display_text="First Speed Song",
downloaded_bytes=4 * 1024 * 1024,
total_bytes=8 * 1024 * 1024,
speed_bytes_per_sec=2 * 1024 * 1024,
progress_percent=50.0,
)
self.assertTrue(repo.mark_item_succeeded(first_item_id))
second_item_id = repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key="song:second-speed",
song_id=20,
status=ItemStatus.PENDING,
payload={"row": {"id": 20, "name": "Second Speed Song"}},
)
repo.claim_item(item_id=second_item_id, worker_name="download-1")
api_response = client.get("/api/dashboard")
self.assertEqual(200, api_response.status_code)
payload = api_response.json()
self.assertEqual("0 B/s", payload["transfer_stats"]["download_speed_text"])
worker = next(worker for worker in payload["workers"] if worker["worker_name"] == "download-1")
self.assertEqual(second_item_id, int(worker["current_job_item_id"]))
self.assertEqual("0 B/s", worker["speed_text"])
def test_dashboard_transfer_stats_ignore_stale_historical_workers(self):
from musicdl.catalogsync.ops.models import ItemStatus, JobStatus, StageStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
live_job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.RUNNING,
)
live_stage_id = repo.create_stage(
job_run_id=live_job_id,
stage_type="download",
seq_no=1,
status=StageStatus.RUNNING,
)
live_item_id = repo.create_item(
job_stage_id=live_stage_id,
item_type="song_download",
item_key="song:live-bandwidth",
song_id=91,
status=ItemStatus.PENDING,
payload={"row": {"id": 91, "name": "Live Speed Song"}},
)
repo.claim_item(item_id=live_item_id, worker_name="download-live-1")
repo.update_worker_state(
"download-live-1",
status="running",
current_job_item_id=live_item_id,
current_song_id=91,
current_display_text="Live Speed Song",
downloaded_bytes=4 * 1024 * 1024,
total_bytes=8 * 1024 * 1024,
speed_bytes_per_sec=2 * 1024 * 1024,
progress_percent=50.0,
)
stale_job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.COMPLETED,
)
stale_stage_id = repo.create_stage(
job_run_id=stale_job_id,
stage_type="download",
seq_no=1,
status=StageStatus.COMPLETED,
)
stale_item_id = repo.create_item(
job_stage_id=stale_stage_id,
item_type="song_download",
item_key="song:stale-bandwidth",
song_id=92,
status=ItemStatus.PENDING,
payload={"row": {"id": 92, "name": "Stale Speed Song"}},
)
repo.claim_item(item_id=stale_item_id, worker_name="download-stale-1")
repo.update_worker_state(
"download-stale-1",
status="running",
current_job_item_id=stale_item_id,
current_song_id=92,
current_display_text="Stale Speed Song",
downloaded_bytes=80 * 1024 * 1024,
total_bytes=80 * 1024 * 1024,
speed_bytes_per_sec=96 * 1024 * 1024,
progress_percent=100.0,
)
self.assertTrue(repo.mark_item_succeeded(stale_item_id))
with repo._connection() as conn:
conn.execute(
"""
UPDATE job_workers
SET
status = 'running',
current_job_item_id = ?,
current_song_id = ?,
current_display_text = ?,
downloaded_bytes = ?,
total_bytes = ?,
speed_bytes_per_sec = ?,
progress_percent = ?
WHERE worker_name = ?
""",
(
stale_item_id,
92,
"Stale Speed Song",
80 * 1024 * 1024,
80 * 1024 * 1024,
96 * 1024 * 1024,
100.0,
"download-stale-1",
),
)
api_response = client.get("/api/dashboard")
self.assertEqual(200, api_response.status_code)
payload = api_response.json()
self.assertEqual("2.0 MB/s", payload["transfer_stats"]["download_speed_text"])
self.assertEqual(
["download-live-1"],
[worker["worker_name"] for worker in payload["workers"]],
)
def test_api_dashboard_includes_paused_jobs_in_task_center_rows(self):
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
paused_job = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.PAUSED,
playlist_scope={"playlist_ids": [77]},
)
response = client.get("/api/dashboard")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertIn("task_rows", payload)
by_id = {int(row["id"]): row for row in payload["task_rows"]}
self.assertIn(paused_job, by_id)
self.assertEqual("paused", by_id[paused_job]["status"])
def test_api_dashboard_includes_completed_jobs_in_task_center_rows(self):
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
completed_job = repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.COMPLETED,
playlist_scope={"playlist_ids": [88]},
)
response = client.get("/api/dashboard")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertIn("task_rows", payload)
by_id = {int(row["id"]): row for row in payload["task_rows"]}
self.assertIn(completed_job, by_id)
self.assertEqual("completed", by_id[completed_job]["status"])
def test_api_dashboard_can_omit_task_rows_for_lightweight_refresh(self):
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
repo = OpsRepository(db_path)
repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.RUNNING,
)
response = client.get("/api/dashboard?include_task_rows=false")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertNotIn("task_rows", payload)
self.assertIn("summary", payload)
self.assertIn("workers", payload)
def test_api_local_duplicates_scan_returns_summary_and_groups(self):
from musicdl.catalogsync.models import CatalogSong
from musicdl.catalogsync.repository import CatalogRepository
client, db_path, _ = self._build_client()
repo = CatalogRepository(db_path)
song_id = repo.upsert_song(
CatalogSong(
platform="qq",
remote_song_id="api-dup-song",
name="API Duplicate Song",
singers="Singer API",
ext="flac",
file_size_bytes=5,
quality_label="lossless",
metadata={},
)
)
library_root = Path(db_path).parent / "library"
backend_id = repo.ensure_local_backend(library_root, name="default-local", is_default=True)
repo.record_local_file(
song_id=song_id,
backend_id=backend_id,
relative_path="Singer API/API Duplicate Song.flac",
file_size_bytes=5,
ext="flac",
quality_label="lossless",
)
repo.record_local_file(
song_id=song_id,
backend_id=backend_id,
relative_path="Singer API/API Duplicate Song (1).flac",
file_size_bytes=5,
ext="flac",
quality_label="lossless",
)
(library_root / "Singer API").mkdir(parents=True, exist_ok=True)
(library_root / "Singer API" / "API Duplicate Song.flac").write_bytes(b"12345")
(library_root / "Singer API" / "API Duplicate Song (1).flac").write_bytes(b"12345")
response = client.get("/api/maintenance/local-duplicates")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(1, payload["summary"]["duplicate_group_count"])
self.assertEqual(1, payload["summary"]["duplicate_location_count"])
self.assertEqual(1, len(payload["groups"]))
group = payload["groups"][0]
self.assertEqual(song_id, group["song_id"])
self.assertEqual("Singer API/API Duplicate Song.flac", group["keep"]["locator"])
self.assertEqual(
"Singer API/API Duplicate Song (1).flac",
group["duplicates"][0]["locator"],
)
def test_api_local_duplicates_dedupe_inactivates_duplicates_and_redirects_references(self):
from musicdl.catalogsync.models import CatalogSong
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
from musicdl.catalogsync.repository import CatalogRepository
client, db_path, _ = self._build_client()
repo = CatalogRepository(db_path)
song_id = repo.upsert_song(
CatalogSong(
platform="qq",
remote_song_id="api-dup-song-dedupe",
name="API Dedupe Song",
singers="Singer API",
ext="flac",
file_size_bytes=6,
quality_label="lossless",
metadata={},
)
)
library_root = Path(db_path).parent / "library"
backend_id = repo.ensure_local_backend(library_root, name="default-local", is_default=True)
asset_id = repo.record_local_file(
song_id=song_id,
backend_id=backend_id,
relative_path="Singer API/API Dedupe Song.flac",
file_size_bytes=6,
ext="flac",
quality_label="lossless",
)
repo.record_local_file(
song_id=song_id,
backend_id=backend_id,
relative_path="Singer API/API Dedupe Song (1).flac",
file_size_bytes=6,
ext="flac",
quality_label="lossless",
)
(library_root / "Singer API").mkdir(parents=True, exist_ok=True)
(library_root / "Singer API" / "API Dedupe Song.flac").write_bytes(b"123456")
duplicate_path = library_root / "Singer API" / "API Dedupe Song (1).flac"
duplicate_path.write_bytes(b"123456")
canonical_location = repo._fetchone(
"SELECT id FROM file_locations WHERE locator = ?",
("Singer API/API Dedupe Song.flac",),
)
duplicate_location = repo._fetchone(
"SELECT id FROM file_locations WHERE locator = ?",
("Singer API/API Dedupe Song (1).flac",),
)
remote_backend_id = repo.upsert_object_storage_backend(
name="test-bucket",
container_name="music",
endpoint="https://s3.example.invalid",
region=None,
base_prefix="catalogsync",
credential_env_prefix="CATALOGSYNC_TEST",
public_base_url="https://cdn.example.invalid",
)
upload_task_id = repo.enqueue_upload_task(
file_asset_id=asset_id,
source_location_id=int(duplicate_location["id"]),
target_backend_id=remote_backend_id,
target_container_name="music",
target_locator="Singer API/API Dedupe Song.flac",
)
ops_repo = OpsRepository(db_path)
job_id = ops_repo.create_job(
job_type="upload_only",
config_snapshot={},
status=JobStatus.QUEUED,
)
stage_id = ops_repo.create_stage(job_run_id=job_id, stage_type="upload", seq_no=1)
item_id = ops_repo.create_item(
job_stage_id=stage_id,
item_type="song_upload",
item_key="upload:api-dedupe-song",
song_id=song_id,
file_location_id=int(duplicate_location["id"]),
)
response = client.post("/api/maintenance/local-duplicates/dedupe")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(0, payload["summary"]["duplicate_group_count"])
self.assertEqual(1, payload["execution"]["inactive_location_count"])
self.assertEqual(1, payload["execution"]["deleted_file_count"])
self.assertEqual(1, payload["execution"]["repointed_upload_task_count"])
self.assertEqual(1, payload["execution"]["repointed_job_item_count"])
duplicate_row = repo._fetchone(
"SELECT status FROM file_locations WHERE id = ?",
(int(duplicate_location["id"]),),
)
self.assertEqual("inactive", duplicate_row["status"])
upload_row = repo._fetchone(
"SELECT source_location_id FROM upload_tasks WHERE id = ?",
(upload_task_id,),
)
self.assertEqual(int(canonical_location["id"]), int(upload_row["source_location_id"]))
job_item = ops_repo._fetchone(
"SELECT file_location_id FROM job_items WHERE id = ?",
(item_id,),
)
self.assertEqual(int(canonical_location["id"]), int(job_item["file_location_id"]))
self.assertFalse(duplicate_path.exists())
def test_api_local_duplicates_dedupe_rejects_while_jobs_are_running(self):
from musicdl.catalogsync.models import CatalogSong
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
from musicdl.catalogsync.repository import CatalogRepository
client, db_path, _ = self._build_client()
repo = CatalogRepository(db_path)
song_id = repo.upsert_song(
CatalogSong(
platform="qq",
remote_song_id="api-dup-song-blocked",
name="API Blocked Song",
singers="Singer API",
ext="flac",
file_size_bytes=4,
quality_label="lossless",
metadata={},
)
)
library_root = Path(db_path).parent / "library"
backend_id = repo.ensure_local_backend(library_root, name="default-local", is_default=True)
repo.record_local_file(
song_id=song_id,
backend_id=backend_id,
relative_path="Singer API/API Blocked Song.flac",
file_size_bytes=4,
ext="flac",
quality_label="lossless",
)
repo.record_local_file(
song_id=song_id,
backend_id=backend_id,
relative_path="Singer API/API Blocked Song (1).flac",
file_size_bytes=4,
ext="flac",
quality_label="lossless",
)
(library_root / "Singer API").mkdir(parents=True, exist_ok=True)
(library_root / "Singer API" / "API Blocked Song.flac").write_bytes(b"1234")
(library_root / "Singer API" / "API Blocked Song (1).flac").write_bytes(b"1234")
ops_repo = OpsRepository(db_path)
ops_repo.create_job(
job_type="download_only",
config_snapshot={},
status=JobStatus.RUNNING,
)
response = client.post("/api/maintenance/local-duplicates/dedupe")
self.assertEqual(409, response.status_code)
self.assertIn("running", response.json()["detail"])
def test_api_playlists_supports_pagination_and_filter_validation(self):
client, db_path, _ = self._build_client()
self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="page-1",
name="Page 1",
play_count=123456,
)
self._seed_playlist(
db_path,
platform="qq",
pool_kind="playlist_square",
remote_id="page-2",
name="Page 2",
)
self._seed_playlist(
db_path,
platform="netease",
pool_kind="manual_file",
remote_id="page-3",
name="Page 3",
)
page_response = client.get("/api/playlists?page=1&page_size=20")
self.assertEqual(200, page_response.status_code)
page_payload = page_response.json()
self.assertIn("items", page_payload)
self.assertIn("total_count", page_payload)
self.assertIn("page_size", page_payload)
self.assertEqual(20, page_payload["page_size"])
self.assertEqual(3, page_payload["total_count"])
self.assertEqual(3, len(page_payload["items"]))
by_name = {item["name"]: item for item in page_payload["items"]}
self.assertEqual(123456, by_name["Page 1"]["play_count"])
empty_status_response = client.get("/api/playlists?status=&page=1&page_size=20")
self.assertEqual(200, empty_status_response.status_code)
empty_status_payload = empty_status_response.json()
self.assertEqual(page_payload["total_count"], empty_status_payload["total_count"])
self.assertEqual(page_payload["page_size"], empty_status_payload["page_size"])
self.assertEqual(
[item["id"] for item in page_payload["items"]],
[item["id"] for item in empty_status_payload["items"]],
)
empty_wanted_response = client.get("/api/playlists?wanted_only=&page=1&page_size=20")
self.assertEqual(200, empty_wanted_response.status_code)
empty_wanted_payload = empty_wanted_response.json()
self.assertEqual(page_payload["total_count"], empty_wanted_payload["total_count"])
self.assertEqual(page_payload["page_size"], empty_wanted_payload["page_size"])
self.assertEqual(
[item["id"] for item in page_payload["items"]],
[item["id"] for item in empty_wanted_payload["items"]],
)
playlists_page_with_empty_status = client.get("/playlists?status=")
self.assertEqual(200, playlists_page_with_empty_status.status_code)
self.assertIn("Page 1", playlists_page_with_empty_status.text)
playlists_page_with_empty_wanted = client.get("/playlists?wanted_only=")
self.assertEqual(200, playlists_page_with_empty_wanted.status_code)
self.assertIn("Page 1", playlists_page_with_empty_wanted.text)
invalid_status = client.get("/api/playlists?status=bad-status")
self.assertEqual(422, invalid_status.status_code)
invalid_wanted_only = client.get("/api/playlists?wanted_only=maybe")
self.assertEqual(422, invalid_wanted_only.status_code)
invalid_page_size = client.get("/api/playlists?page_size=30")
self.assertEqual(422, invalid_page_size.status_code)
def test_api_playlists_supports_sorting_and_exposes_collected_song_count(self):
client, db_path, _ = self._build_client()
self._seed_playlist(
db_path,
platform="qq",
pool_kind="playlist_square",
remote_id="sort-api-001",
name="Zulu API Playlist",
play_count=100,
)
self._seed_playlist(
db_path,
platform="netease",
pool_kind="playlist_square",
remote_id="sort-api-002",
name="Alpha API Playlist",
play_count=300,
)
self._seed_playlist(
db_path,
platform="kuwo",
pool_kind="playlist_square",
remote_id="sort-api-003",
name="Collected API Playlist",
collected_song_count=42,
)
response = client.get("/api/playlists?page=1&page_size=20&sort_by=name&sort_dir=asc")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(
["Alpha API Playlist", "Collected API Playlist", "Zulu API Playlist"],
[item["name"] for item in payload["items"]],
)
collected_row = next(item for item in payload["items"] if item["name"] == "Collected API Playlist")
self.assertEqual(42, collected_row["collected_song_count"])
self.assertEqual(42, collected_row["display_song_count"])
self.assertTrue(collected_row["is_song_count_estimated"])
play_count_response = client.get("/api/playlists?page=1&page_size=20&sort_by=play_count&sort_dir=desc")
self.assertEqual(200, play_count_response.status_code)
play_count_payload = play_count_response.json()
self.assertEqual(
["Alpha API Playlist", "Zulu API Playlist", "Collected API Playlist"],
[item["name"] for item in play_count_payload["items"]],
)
def test_playlists_page_renders_management_controls_and_filters(self):
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="page-render-1",
name="Playlist Render Seed",
play_count=7654321,
)
song_a = self._seed_song(
db_path,
platform="qq",
remote_id="page-render-song-1",
name="Playlist Render Song 1",
)
song_b = self._seed_song(
db_path,
platform="qq",
remote_id="page-render-song-2",
name="Playlist Render Song 2",
)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_a, position=1)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_b, position=2)
self._mark_local_downloaded(
db_path,
song_id=song_a,
relative_path="qq/Singer A/page-render-song-1.mp3",
)
response = client.get("/playlists")
self.assertEqual(200, response.status_code)
html = response.text
self.assertIn('name="status"', html)
self.assertIn('name="page_size"', html)
self.assertIn('name="wanted_only"', html)
self.assertIn("data-playlist-select-all", html)
self.assertIn("data-playlist-clear-selection", html)
self.assertIn('data-playlist-action="download"', html)
self.assertNotIn('data-playlist-action="sync-download"', html)
self.assertIn('data-playlist-action="export-selected"', html)
self.assertIn("Export Selected", html)
self.assertIn('data-playlist-action="mark-wanted"', html)
self.assertIn('data-playlist-action="unmark-wanted"', html)
self.assertIn('value="collect_only"', html)
self.assertIn("Collect Playlist Sources", html)
self.assertIn("data-playlist-pagination", html)
self.assertIn("data-playlists-page", html)
self.assertIn("data-playlist-checkbox", html)
self.assertIn("sort_by=platform", html)
self.assertIn("sort_by=play_count", html)
self.assertIn("<th>Progress</th>", html)
self.assertIn("<th>Status</th>", html)
self.assertIn("<th>Downloaded</th>", html)
self.assertIn("1 / 2", html)
self.assertIn("50%", html)
self.assertIn("7654321", html)
self.assertIn("Playlist Render Seed", html)
self.assertNotIn("Idle", html)
def test_playlists_page_renders_sortable_headers_and_collected_song_count_hint(self):
client, db_path, _ = self._build_client()
self._seed_playlist(
db_path,
platform="qq",
pool_kind="playlist_square",
remote_id="playlist-render-collected",
name="Collected Render Playlist",
collected_song_count=42,
)
response = client.get("/playlists?sort_by=name&sort_dir=asc")
self.assertEqual(200, response.status_code)
html = response.text
self.assertIn("sort_by=id", html)
self.assertIn("sort_by=platform", html)
self.assertIn("sort_by=name", html)
self.assertIn("sort_by=play_count", html)
self.assertIn('data-playlist-sort-link="id"', html)
self.assertIn('data-playlist-sort-link="platform"', html)
self.assertIn('data-playlist-sort-link="name"', html)
self.assertIn('data-playlist-sort-link="play_count"', html)
self.assertIn('data-playlist-sort-indicator="name">^</span>', html)
self.assertIn("Collected 42", html)
def test_playlists_page_applies_name_sort_order_to_rendered_rows(self):
client, db_path, _ = self._build_client()
self._seed_playlist(
db_path,
platform="qq",
pool_kind="playlist_square",
remote_id="playlist-sort-page-1",
name="Zulu Page Playlist",
play_count=100,
)
self._seed_playlist(
db_path,
platform="netease",
pool_kind="playlist_square",
remote_id="playlist-sort-page-2",
name="Alpha Page Playlist",
play_count=300,
)
self._seed_playlist(
db_path,
platform="kuwo",
pool_kind="playlist_square",
remote_id="playlist-sort-page-3",
name="Middle Page Playlist",
play_count=200,
)
response = client.get("/playlists?sort_by=name&sort_dir=asc")
self.assertEqual(200, response.status_code)
html = response.text
alpha_index = html.index("Alpha Page Playlist")
middle_index = html.index("Middle Page Playlist")
zulu_index = html.index("Zulu Page Playlist")
self.assertLess(alpha_index, middle_index)
self.assertLess(middle_index, zulu_index)
def test_playlists_page_renders_sync_selected_playlists_action(self):
client, db_path, _ = self._build_client()
self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="playlist-render-sync",
name="Playlist Render Sync",
)
response = client.get("/playlists")
self.assertEqual(200, response.status_code)
html = response.text
self.assertIn("data-playlists-page", html)
self.assertIn('data-playlist-action="sync"', html)
self.assertIn("data-playlist-select-all", html)
def test_playlists_page_renders_clickable_synced_playlist_name_and_song_modal_shell(self):
client, db_path, _ = self._build_client()
synced_playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="playlist-preview-ready",
name="Playlist Preview Ready",
)
empty_playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="playlist-preview-empty",
name="Playlist Preview Empty",
)
synced_song_id = self._seed_song(
db_path,
platform="qq",
remote_id="playlist-preview-song-1",
name="Playlist Preview Song 1",
)
self._link_playlist_song(
db_path,
playlist_id=synced_playlist_id,
song_id=synced_song_id,
position=1,
)
response = client.get("/playlists")
self.assertEqual(200, response.status_code)
html = response.text
self.assertIn(f'data-playlist-open-songs="{synced_playlist_id}"', html)
self.assertNotIn(f'data-playlist-open-songs="{empty_playlist_id}"', html)
self.assertIn("data-playlist-songs-modal", html)
self.assertIn("data-playlist-export", html)
def test_api_playlist_songs_exposes_export_ready_song_metadata_and_locations(self):
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="playlist-export-ready",
name="Playlist Export Ready",
play_count=987654,
)
song_id = self._seed_song(
db_path,
platform="qq",
remote_id="playlist-export-song-1",
name="Playlist Export Song 1",
singers="Singer Export",
)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_id, position=1)
self._mark_remote_uploaded(
db_path,
song_id=song_id,
relative_path="qq/Singer Export/playlist-export-song-1.mp3",
public_url="https://cdn.example.invalid/qq/Singer%20Export/playlist-export-song-1.mp3",
download_url="https://download.example.invalid/qq/Singer%20Export/playlist-export-song-1.mp3",
)
response = client.get(f"/api/playlists/{playlist_id}/songs")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertIn("playlist", payload)
self.assertIn("items", payload)
self.assertEqual(playlist_id, payload["playlist"]["id"])
self.assertEqual("Playlist Export Ready", payload["playlist"]["name"])
self.assertEqual("qq", payload["playlist"]["platform"])
self.assertEqual(987654, payload["playlist"]["play_count"])
self.assertEqual(1, len(payload["items"]))
row = payload["items"][0]
self.assertEqual(song_id, row["song_id"])
self.assertEqual("Playlist Export Song 1", row["name"])
self.assertEqual("Singer Export", row["singers"])
self.assertEqual("mp3", row["ext"])
self.assertEqual(128, row["file_size_bytes"])
self.assertTrue(str(row["local_file_path"]).endswith("qq\\Singer Export\\playlist-export-song-1.mp3"))
self.assertEqual(1, len(row["uploaded_locations"]))
uploaded_row = row["uploaded_locations"][0]
self.assertEqual("catalog-cloud", uploaded_row["backend_name"])
self.assertEqual("object_storage", uploaded_row["backend_type"])
self.assertEqual(
"https://cdn.example.invalid/qq/Singer%20Export/playlist-export-song-1.mp3",
uploaded_row["url"],
)
def test_api_playlist_export_folder_returns_existing_playlist_directory(self):
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.web import create_app
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
root = Path(tmpdir)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text(
f"ROOT_DIR={root.as_posix()}\nDOWNLOAD_SOURCES=qq\n",
encoding="utf-8",
)
initialize_database(db_path, default_library_root=root / "library").close()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="playlist-export-folder",
name="Playlist Export Folder",
play_count=456789,
)
playlist_dir = root / "playlists" / f"Playlist Export Folder_{playlist_id}"
covers_dir = playlist_dir / "covers"
covers_dir.mkdir(parents=True, exist_ok=True)
(playlist_dir / "playlist.yaml").write_text(
"\n".join(
[
f"playlist_id: {playlist_id}",
"playlist_name: Playlist Export Folder",
"platform: qq",
"play_count: 456789",
"",
]
),
encoding="utf-8",
)
(playlist_dir / ".playlist_meta.json").write_text(
json.dumps(
{
"playlist_id": playlist_id,
"platform": "qq",
"remote_playlist_id": "playlist-export-folder",
"name": "Playlist Export Folder",
},
ensure_ascii=False,
),
encoding="utf-8",
)
(covers_dir / "playlist-cover.jpg").write_bytes(b"fake-cover")
app = create_app(db_path=db_path, env_path=env_path)
client = TestClient(app)
self.addCleanup(client.close)
response = client.get(f"/api/playlists/{playlist_id}/export-folder")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(playlist_id, payload["playlist"]["id"])
self.assertTrue(payload["folder"]["exists"])
self.assertTrue(str(payload["folder"]["path"]).endswith(f"Playlist Export Folder_{playlist_id}"))
relative_paths = {str(row.get("relative_path") or "") for row in payload["folder"]["files"]}
self.assertIn("playlist.yaml", relative_paths)
self.assertIn("covers/playlist-cover.jpg", relative_paths)
def test_api_playlist_export_folder_refreshes_yaml_from_latest_db_state(self):
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.web import create_app
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
root = Path(tmpdir)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text(
f"ROOT_DIR={root.as_posix()}\nDOWNLOAD_SOURCES=qq\n",
encoding="utf-8",
)
initialize_database(db_path, default_library_root=root / "library").close()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="playlist-export-refresh",
name="Playlist Export Refresh",
play_count=456789,
)
song_id = self._seed_song(
db_path,
platform="qq",
remote_id="playlist-export-refresh-song",
name="Playlist Export Refresh Song",
singers="Singer Refresh",
)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_id, position=1)
self._mark_local_downloaded(
db_path,
song_id=song_id,
relative_path="qq/Singer Refresh/playlist-export-refresh-song.mp3",
)
playlist_dir = root / "playlists" / f"Playlist Export Refresh_{playlist_id}"
covers_dir = playlist_dir / "covers"
covers_dir.mkdir(parents=True, exist_ok=True)
stale_yaml_path = playlist_dir / "playlist.yaml"
stale_yaml_path.write_text(
"\n".join(
[
f"playlist_id: {playlist_id}",
"playlist_name: Playlist Export Refresh",
"platform: qq",
"songs:",
" - local_song_id: 1",
" local_file_path: null",
"",
]
),
encoding="utf-8",
)
(playlist_dir / ".playlist_meta.json").write_text(
json.dumps(
{
"playlist_id": playlist_id,
"platform": "qq",
"remote_playlist_id": "playlist-export-refresh",
"name": "Playlist Export Refresh",
},
ensure_ascii=False,
),
encoding="utf-8",
)
app = create_app(db_path=db_path, env_path=env_path)
client = TestClient(app)
self.addCleanup(client.close)
response = client.get(f"/api/playlists/{playlist_id}/export-folder")
refreshed_yaml = stale_yaml_path.read_text(encoding="utf-8")
self.assertEqual(200, response.status_code)
self.assertIn("playlist-export-refresh-song.mp3", refreshed_yaml)
self.assertNotIn("local_file_path: null", refreshed_yaml)
def test_job_detail_page_and_api_include_playlist_progress(self):
from musicdl.catalogsync.ops.models import ItemStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="job-detail-progress-1",
name="Job Detail Playlist",
)
song_a = self._seed_song(
db_path,
platform="qq",
remote_id="job-detail-song-1",
name="Job Detail Song 1",
)
song_b = self._seed_song(
db_path,
platform="qq",
remote_id="job-detail-song-2",
name="Job Detail Song 2",
)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_a, position=1)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_b, position=2)
self._mark_local_downloaded(
db_path,
song_id=song_a,
relative_path="qq/Singer A/job-detail-song-1.mp3",
)
repo = OpsRepository(db_path)
job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
playlist_scope={"playlist_ids": [playlist_id]},
)
stage_id = repo.create_stage(job_run_id=job_id, stage_type="download", seq_no=1)
repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key=f"song:{song_b}",
song_id=song_b,
status=ItemStatus.PENDING,
)
api_response = client.get(f"/api/jobs/{job_id}")
self.assertEqual(200, api_response.status_code)
api_payload = api_response.json()
self.assertIn("playlist_progress", api_payload)
self.assertEqual(1, len(api_payload["playlist_progress"]))
row = api_payload["playlist_progress"][0]
self.assertEqual(playlist_id, row["playlist_id"])
self.assertEqual("Job Detail Playlist", row["playlist_name"])
self.assertEqual(2, row["total_songs"])
self.assertEqual(1, row["downloaded_songs"])
self.assertEqual(1, row["pending_songs"])
self.assertEqual(50, row["progress_percent"])
page_response = client.get(f"/jobs/{job_id}")
self.assertEqual(200, page_response.status_code)
html = page_response.text
self.assertIn("Playlist Progress", html)
self.assertIn("Job Detail Playlist", html)
self.assertIn("1 / 2", html)
self.assertIn("50%", html)
def test_api_job_playlist_songs_exposes_non_music_resource_note(self):
from musicdl.catalogsync.ops.models import ItemStatus
from musicdl.catalogsync.ops.repository import OpsRepository
client, db_path, _ = self._build_client()
playlist_id = self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id="job-detail-non-music-1",
name="Job Detail Non Music Playlist",
)
song_id = self._seed_song(
db_path,
platform="qq",
remote_id="qqtop_75_non_music_seed",
name="Audio Program",
singers="Narrator",
metadata={
"snapshot": {
"raw_data": {
"search": {
"qq_toplist_fallback": True,
}
}
}
},
)
self._link_playlist_song(db_path, playlist_id=playlist_id, song_id=song_id, position=1)
repo = OpsRepository(db_path)
job_id = repo.create_job(
job_type="download_only",
config_snapshot={},
playlist_scope={"playlist_ids": [playlist_id]},
)
stage_id = repo.create_stage(job_run_id=job_id, stage_type="download", seq_no=1)
repo.create_item(
job_stage_id=stage_id,
item_type="song_download",
item_key=f"song:{song_id}",
song_id=song_id,
status=ItemStatus.SKIPPED,
payload={"row": {"id": song_id}},
)
response = client.get(f"/api/jobs/{job_id}/playlists/{playlist_id}/songs")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertIn("items", payload)
self.assertEqual(1, len(payload["items"]))
row = payload["items"][0]
self.assertEqual(song_id, row["song_id"])
self.assertEqual("skipped", row["status"])
self.assertTrue(row["is_non_music_resource"])
self.assertIn("非音乐资源", row["status_note"])
def test_playlists_pagination_links_encode_keyword_query(self):
client, db_path, _ = self._build_client()
keyword = "A & B ="
for index in range(1, 42):
self._seed_playlist(
db_path,
platform="qq",
pool_kind="manual_file",
remote_id=f"page-encoded-{index}",
name=f"{keyword} Playlist {index}",
)
response = client.get(
"/playlists",
params={"page": 2, "page_size": 20, "keyword": keyword},
)
self.assertEqual(200, response.status_code)
html = response.text
self.assertIn("Previous", html)
self.assertIn("Next", html)
self.assertTrue(
"keyword=A+%26+B+%3D" in html or "keyword=A%20%26%20B%20%3D" in html
)
def test_api_playlist_bulk_endpoints_reject_empty_playlist_ids(self):
client, _, _ = self._build_client()
for path in (
"/api/playlists/mark-wanted",
"/api/playlists/unmark-wanted",
"/api/playlists/sync",
"/api/playlists/download",
"/api/playlists/sync-download",
):
response = client.post(path, json={"playlist_ids": []})
self.assertEqual(422, response.status_code, msg=path)
def test_embedded_runner_recovers_from_transient_loop_failure(self):
from musicdl.catalogsync.db import initialize_database
from musicdl.catalogsync.ops.models import JobStatus
from musicdl.catalogsync.ops.repository import OpsRepository
from musicdl.catalogsync.ops.web import create_app
tmpdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(tmpdir.cleanup)
root = Path(tmpdir.name)
db_path = root / "catalogsync.db"
env_path = root / "catalogsync.env"
env_path.write_text("ROOT_DIR=/music\nDOWNLOAD_SOURCES=qq\n", encoding="utf-8")
initialize_database(db_path).close()
repo = OpsRepository(db_path)
job_id = repo.create_job(job_type="collect_only", config_snapshot={})
attempts = {"count": 0}
def flaky_loop_once(self):
attempts["count"] += 1
if attempts["count"] == 1:
raise RuntimeError("transient embedded runner failure")
job = self.repository.claim_and_mark_next_runnable_job()
if job is None:
return False
self.repository.mark_job_finished(job.id, status=JobStatus.COMPLETED)
return True
app = create_app(
db_path=db_path,
env_path=env_path,
start_runner=True,
runner_sleep_seconds=0.01,
)
with patch("musicdl.catalogsync.ops.runner.OpsRunner.loop_once", new=flaky_loop_once):
with TestClient(app):
deadline = time.time() + 1.5
while time.time() < deadline:
if repo.get_job(job_id).status == JobStatus.COMPLETED:
break
time.sleep(0.05)
job = repo.get_job(job_id)
self.assertGreaterEqual(attempts["count"], 2)
self.assertEqual(JobStatus.COMPLETED, job.status)
if __name__ == "__main__":
unittest.main()