2209 lines
102 KiB
Python
2209 lines
102 KiB
Python
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
import warnings
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
|
|
|
|
class FakeHTTPResponse:
|
|
def __init__(self, payload, status_code=200):
|
|
self._payload = payload
|
|
self.status_code = status_code
|
|
|
|
def raise_for_status(self):
|
|
if int(self.status_code) >= 400:
|
|
raise RuntimeError(f"http {self.status_code}")
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
|
|
class FakeImageResponse:
|
|
def __init__(self, content: bytes, status_code: int = 200, content_type: str = "image/jpeg"):
|
|
self.content = bytes(content)
|
|
self.status_code = int(status_code)
|
|
self.headers = {"Content-Type": content_type}
|
|
|
|
def raise_for_status(self):
|
|
if int(self.status_code) >= 400:
|
|
raise RuntimeError(f"http {self.status_code}")
|
|
|
|
|
|
class CatalogServiceTests(unittest.TestCase):
|
|
@staticmethod
|
|
def _playlist_square_candidate(platform: str, remote_id: str):
|
|
from musicdl.catalogsync.models import PlaylistCandidate
|
|
|
|
playlist_url = {
|
|
"netease": f"https://music.163.com/#/playlist?id={remote_id}",
|
|
"qq": f"https://y.qq.com/n/ryqq/playlist/{remote_id}",
|
|
"kuwo": f"https://www.kuwo.cn/playlist_detail/{remote_id}",
|
|
}[platform]
|
|
return PlaylistCandidate(
|
|
platform=platform,
|
|
pool_kind="playlist_square",
|
|
remote_id=remote_id,
|
|
name=f"{platform}-playlist-{remote_id}",
|
|
url=playlist_url,
|
|
)
|
|
|
|
def test_collect_playlists_paginates_netease_playlist_square(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
|
|
netease_collector = SimpleNamespace(
|
|
collect_playlist_square=Mock(
|
|
side_effect=[
|
|
[self._playlist_square_candidate("netease", "10001")],
|
|
[self._playlist_square_candidate("netease", "10002")],
|
|
[],
|
|
]
|
|
),
|
|
collect_toplist=Mock(return_value=[]),
|
|
)
|
|
service = CatalogSyncService(
|
|
repository=repo,
|
|
collectors={"netease": netease_collector},
|
|
)
|
|
|
|
counts = service.collect_playlists(sources=["netease"], include_toplist=False)
|
|
|
|
self.assertEqual(2, counts["playlist_square"])
|
|
self.assertEqual(3, netease_collector.collect_playlist_square.call_count)
|
|
|
|
def test_collect_playlists_paginates_kuwo_playlist_square(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
|
|
kuwo_collector = SimpleNamespace(
|
|
collect_playlist_square=Mock(
|
|
side_effect=[
|
|
[self._playlist_square_candidate("kuwo", "20001")],
|
|
[self._playlist_square_candidate("kuwo", "20002")],
|
|
[],
|
|
]
|
|
),
|
|
collect_toplist=Mock(return_value=[]),
|
|
)
|
|
service = CatalogSyncService(
|
|
repository=repo,
|
|
collectors={"kuwo": kuwo_collector},
|
|
)
|
|
|
|
counts = service.collect_playlists(sources=["kuwo"], include_toplist=False)
|
|
|
|
self.assertEqual(2, counts["playlist_square"])
|
|
self.assertEqual(3, kuwo_collector.collect_playlist_square.call_count)
|
|
|
|
def test_collect_playlists_handles_qq_square_failure_with_clear_boundary(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
|
|
qq_collector = SimpleNamespace(
|
|
collect_playlist_square=Mock(side_effect=RuntimeError("qq square unavailable")),
|
|
collect_toplist=Mock(return_value=[]),
|
|
)
|
|
netease_collector = SimpleNamespace(
|
|
collect_playlist_square=Mock(
|
|
return_value=[self._playlist_square_candidate("netease", "30001")]
|
|
),
|
|
collect_toplist=Mock(return_value=[]),
|
|
)
|
|
service = CatalogSyncService(
|
|
repository=repo,
|
|
collectors={"qq": qq_collector, "netease": netease_collector},
|
|
)
|
|
|
|
try:
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always", RuntimeWarning)
|
|
counts = service.collect_playlists(
|
|
sources=["qq", "netease"],
|
|
include_toplist=False,
|
|
)
|
|
except RuntimeError as exc: # pragma: no cover - current gap guard
|
|
self.fail(f"collect_playlists should not abort on qq square failure: {exc}")
|
|
|
|
self.assertEqual(1, counts["playlist_square"])
|
|
self.assertEqual(1, netease_collector.collect_playlist_square.call_count)
|
|
runtime_warnings = [warning for warning in caught if issubclass(warning.category, RuntimeWarning)]
|
|
self.assertEqual([], runtime_warnings)
|
|
|
|
def test_collect_playlists_stops_when_playlist_square_pages_repeat_only_duplicates(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
|
|
collector = SimpleNamespace(
|
|
collect_playlist_square=Mock(
|
|
side_effect=[
|
|
[self._playlist_square_candidate("netease", "40001")],
|
|
[self._playlist_square_candidate("netease", "40001")],
|
|
[self._playlist_square_candidate("netease", "40002")],
|
|
]
|
|
),
|
|
collect_toplist=Mock(return_value=[]),
|
|
)
|
|
service = CatalogSyncService(
|
|
repository=repo,
|
|
collectors={"netease": collector},
|
|
)
|
|
|
|
counts = service.collect_playlists(sources=["netease"], include_toplist=False)
|
|
|
|
self.assertEqual(1, counts["playlist_square"])
|
|
self.assertEqual(2, collector.collect_playlist_square.call_count)
|
|
|
|
def test_resolve_playlist_song_infos_uses_snapshot_builder_for_supported_playlist_urls(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repo)
|
|
|
|
playlist_row = {
|
|
"platform": "netease",
|
|
"parse_strategy": "playlist_url",
|
|
"url": "https://music.163.com/#/playlist?id=17745989905",
|
|
}
|
|
|
|
with patch("musicdl.catalogsync.services.build_netease_playlist_song_infos", return_value=["snapshot-song"]) as builder:
|
|
with patch.object(service, "get_client", return_value="netease-client"):
|
|
result = service.resolve_playlist_song_infos(playlist_row)
|
|
|
|
self.assertEqual(["snapshot-song"], result)
|
|
builder.assert_called_once_with("netease-client", playlist_row["url"])
|
|
|
|
def test_resolve_netease_toplist_uses_netease_snapshot_builder(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repo)
|
|
|
|
playlist_row = {
|
|
"platform": "netease",
|
|
"parse_strategy": "netease_toplist",
|
|
"url": "https://music.163.com/#/playlist?id=19723756",
|
|
}
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.services.build_netease_playlist_song_infos",
|
|
return_value=["netease-toplist-song"],
|
|
) as builder:
|
|
with patch.object(service, "get_client", return_value="netease-client"):
|
|
result = service.resolve_playlist_song_infos(playlist_row)
|
|
|
|
self.assertEqual(["netease-toplist-song"], result)
|
|
builder.assert_called_once_with("netease-client", playlist_row["url"])
|
|
|
|
def test_resolve_qq_toplist_falls_back_when_songlist_has_no_data_field(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repo)
|
|
fake_client = SimpleNamespace(
|
|
source="QQMusicClient",
|
|
_constructuniqueworkdir=lambda keyword: f"/tmp/{keyword}",
|
|
_removeduplicates=lambda song_infos: list(song_infos),
|
|
)
|
|
playlist_row = {"remote_playlist_id": "75", "name": "有声榜"}
|
|
v8_payload = {
|
|
"songlist": [
|
|
{"songid": 0, "Franking_value": "0"},
|
|
]
|
|
}
|
|
musicu_payload = {
|
|
"toplist": {
|
|
"code": 0,
|
|
"data": {
|
|
"data": {
|
|
"song": [
|
|
{
|
|
"rank": 1,
|
|
"songId": 0,
|
|
"albumMid": "000HiJZD1F4wUi",
|
|
"title": "道士不好惹",
|
|
"singerName": "萌鹿剧场/骨头-萌鹿剧场",
|
|
},
|
|
{
|
|
"rank": 2,
|
|
"songId": 0,
|
|
"albumMid": "002qjuVq0iug1k",
|
|
"title": "晚安妈妈睡前故事大全",
|
|
"singerName": "QQ音乐儿童官方频道/晚安妈妈",
|
|
},
|
|
]
|
|
}
|
|
},
|
|
}
|
|
}
|
|
mocked_get = Mock(return_value=FakeHTTPResponse(v8_payload))
|
|
mocked_post = Mock(return_value=FakeHTTPResponse(musicu_payload))
|
|
|
|
with patch.object(service, "get_client", return_value=fake_client):
|
|
with patch("musicdl.catalogsync.services.requests.get", mocked_get):
|
|
with patch("musicdl.catalogsync.services.requests.post", mocked_post):
|
|
song_infos = service._resolve_qq_toplist(playlist_row)
|
|
|
|
self.assertEqual(2, len(song_infos))
|
|
self.assertTrue(all(song_info.identifier for song_info in song_infos))
|
|
self.assertEqual("道士不好惹", song_infos[0].song_name)
|
|
self.assertEqual("萌鹿剧场, 骨头-萌鹿剧场", song_infos[0].singers)
|
|
self.assertEqual(1, mocked_get.call_count)
|
|
self.assertEqual(1, mocked_post.call_count)
|
|
|
|
def test_import_manual_playlists_creates_platform_specific_manual_pools(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.manual_playlists import parse_playlist_file
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
playlist_file = Path(tmpdir) / "playlists.txt"
|
|
playlist_file.write_text(
|
|
"\n".join(
|
|
[
|
|
"https://music.163.com/#/playlist?id=17745989905",
|
|
"https://y.qq.com/n/ryqq/playlist/7707261125",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repo)
|
|
|
|
parsed = parse_playlist_file(playlist_file)
|
|
playlist_ids = service.import_manual_playlists(playlist_file, parsed.entries)
|
|
|
|
pools = repo._fetchall(
|
|
"""
|
|
SELECT platform, pool_kind, external_id, name
|
|
FROM playlist_pools
|
|
WHERE pool_kind = 'manual_file'
|
|
ORDER BY platform ASC
|
|
"""
|
|
)
|
|
|
|
self.assertEqual(2, len(playlist_ids))
|
|
self.assertEqual(["netease", "qq"], [row["platform"] for row in pools])
|
|
|
|
def test_sync_specific_playlists_only_processes_requested_playlist_ids(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.models import PlaylistCandidate
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repo)
|
|
|
|
playlist_a = repo.upsert_playlist(
|
|
PlaylistCandidate(
|
|
platform="netease",
|
|
pool_kind="manual_file",
|
|
remote_id="17745989905",
|
|
name="Playlist A",
|
|
url="https://music.163.com/#/playlist?id=17745989905",
|
|
)
|
|
)
|
|
playlist_b = repo.upsert_playlist(
|
|
PlaylistCandidate(
|
|
platform="netease",
|
|
pool_kind="manual_file",
|
|
remote_id="17729789137",
|
|
name="Playlist B",
|
|
url="https://music.163.com/#/playlist?id=17729789137",
|
|
)
|
|
)
|
|
|
|
with patch.object(service, "resolve_playlist_song_infos", return_value=[]) as resolver:
|
|
service.sync_specific_playlists([playlist_b])
|
|
|
|
called_row = resolver.call_args[0][0]
|
|
self.assertEqual(playlist_b, int(called_row["id"]))
|
|
|
|
def test_sync_playlist_row_backfills_netease_play_count_from_playlist_detail(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.models import PlaylistCandidate
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repo)
|
|
playlist_id = repo.upsert_playlist(
|
|
PlaylistCandidate(
|
|
platform="netease",
|
|
pool_kind="manual_file",
|
|
remote_id="17745989905",
|
|
name="Playlist A",
|
|
url="https://music.163.com/#/playlist?id=17745989905",
|
|
)
|
|
)
|
|
playlist_row = repo.list_playlists_by_ids([playlist_id])[0]
|
|
fake_client = SimpleNamespace(
|
|
post=Mock(
|
|
return_value=FakeHTTPResponse(
|
|
{
|
|
"playlist": {
|
|
"playCount": 7654321,
|
|
}
|
|
}
|
|
)
|
|
)
|
|
)
|
|
|
|
with patch.object(service, "resolve_playlist_song_infos", return_value=[]):
|
|
with patch.object(service, "get_client", return_value=fake_client):
|
|
service.sync_playlist_row(playlist_row)
|
|
|
|
refreshed_row = repo.list_playlists_by_ids([playlist_id])[0]
|
|
|
|
self.assertEqual(7654321, refreshed_row["play_count"])
|
|
|
|
def test_sync_playlist_row_backfills_qq_and_kuwo_play_count_from_playlist_detail(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.models import PlaylistCandidate
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repo)
|
|
|
|
qq_playlist_id = repo.upsert_playlist(
|
|
PlaylistCandidate(
|
|
platform="qq",
|
|
pool_kind="manual_file",
|
|
remote_id="7707261125",
|
|
name="QQ Playlist",
|
|
url="https://y.qq.com/n/ryqq/playlist/7707261125",
|
|
)
|
|
)
|
|
kuwo_playlist_id = repo.upsert_playlist(
|
|
PlaylistCandidate(
|
|
platform="kuwo",
|
|
pool_kind="manual_file",
|
|
remote_id="3694434192",
|
|
name="Kuwo Playlist",
|
|
url="https://www.kuwo.cn/playlist_detail/3694434192",
|
|
)
|
|
)
|
|
qq_playlist_row = repo.list_playlists_by_ids([qq_playlist_id])[0]
|
|
kuwo_playlist_row = repo.list_playlists_by_ids([kuwo_playlist_id])[0]
|
|
fake_qq_client = SimpleNamespace(
|
|
get=Mock(
|
|
return_value=FakeHTTPResponse(
|
|
{
|
|
"cdlist": [
|
|
{
|
|
"visitnum": 8527054,
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
)
|
|
fake_kuwo_client = SimpleNamespace(
|
|
get=Mock(
|
|
return_value=FakeHTTPResponse(
|
|
{
|
|
"data": {
|
|
"listencnt": 2196,
|
|
}
|
|
}
|
|
)
|
|
)
|
|
)
|
|
|
|
with patch.object(service, "resolve_playlist_song_infos", return_value=[]):
|
|
with patch.object(
|
|
service,
|
|
"get_client",
|
|
side_effect=lambda platform: {
|
|
"qq": fake_qq_client,
|
|
"kuwo": fake_kuwo_client,
|
|
}[platform],
|
|
):
|
|
service.sync_playlist_row(qq_playlist_row)
|
|
service.sync_playlist_row(kuwo_playlist_row)
|
|
|
|
refreshed_rows = {
|
|
int(row["id"]): row
|
|
for row in repo.list_playlists_by_ids([qq_playlist_id, kuwo_playlist_id])
|
|
}
|
|
|
|
self.assertEqual(8527054, refreshed_rows[qq_playlist_id]["play_count"])
|
|
self.assertEqual(2196, refreshed_rows[kuwo_playlist_id]["play_count"])
|
|
|
|
def test_sync_playlist_row_does_not_write_playlist_artifacts_until_explicit_export(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.models import PlaylistCandidate
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
root = Path(tmpdir)
|
|
db_path = root / "catalogsync.db"
|
|
library_root = root / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repo)
|
|
|
|
playlist_id = repo.upsert_playlist(
|
|
PlaylistCandidate(
|
|
platform="qq",
|
|
pool_kind="manual_file",
|
|
remote_id="7707261125",
|
|
name="Playlist Artifact Ready",
|
|
url="https://y.qq.com/n/ryqq/playlist/7707261125",
|
|
cover_url="https://img.example.invalid/playlist-cover.jpg",
|
|
play_count=123456,
|
|
)
|
|
)
|
|
pool_id = repo.upsert_playlist_pool(
|
|
platform="qq",
|
|
pool_kind="manual_file",
|
|
external_id="manual-file:qq:7707261125",
|
|
name="Manual File QQ",
|
|
url="https://example.invalid/manual/qq/7707261125",
|
|
)
|
|
repo.link_pool_playlist(pool_id, playlist_id)
|
|
playlist_row = repo.list_playlists_by_ids([playlist_id])[0]
|
|
deferred_song = SimpleNamespace(
|
|
source="QQMusicClient",
|
|
identifier="song-artifact-1",
|
|
song_name="Song Artifact 1",
|
|
singers="Singer Artifact",
|
|
album="Album Artifact",
|
|
ext="flac",
|
|
file_size="1 MB",
|
|
file_size_bytes=1024 * 1024,
|
|
cover_url="https://img.example.invalid/song-cover.jpg",
|
|
raw_data={"search": {"id": "song-artifact-1"}, "quality": "lossless"},
|
|
)
|
|
|
|
with patch.object(service, "resolve_playlist_song_infos", return_value=[deferred_song]):
|
|
with patch.object(service, "resolve_playlist_play_count", return_value=123456):
|
|
linked_count = service.sync_playlist_row(playlist_row)
|
|
|
|
playlist_root = root / "playlists"
|
|
self.assertFalse(playlist_root.exists())
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.services.requests.get",
|
|
side_effect=[
|
|
FakeImageResponse(b"playlist-cover"),
|
|
FakeImageResponse(b"song-cover"),
|
|
],
|
|
):
|
|
playlist_dir = service.ensure_playlist_artifacts_for_playlist(playlist_id)
|
|
|
|
self.assertIsNotNone(playlist_dir)
|
|
assert playlist_dir is not None
|
|
self.assertIn("Playlist Artifact Ready", playlist_dir.name)
|
|
self.assertIn(str(playlist_id), playlist_dir.name)
|
|
|
|
yaml_path = playlist_dir / "playlist.yaml"
|
|
self.assertTrue(yaml_path.exists())
|
|
yaml_text = yaml_path.read_text(encoding="utf-8")
|
|
self.assertIn("playlist_id: " + str(playlist_id), yaml_text)
|
|
self.assertIn("playlist_name: Playlist Artifact Ready", yaml_text)
|
|
self.assertIn("play_count: 123456", yaml_text)
|
|
self.assertIn("cover_url: https://img.example.invalid/song-cover.jpg", yaml_text)
|
|
self.assertIn("Song Artifact 1", yaml_text)
|
|
self.assertIn("Album Artifact", yaml_text)
|
|
self.assertIn("platform_song_id: song-artifact-1", yaml_text)
|
|
|
|
covers_dir = playlist_dir / "covers"
|
|
self.assertTrue(covers_dir.exists())
|
|
cover_names = {path.name for path in covers_dir.iterdir() if path.is_file()}
|
|
self.assertIn("playlist-cover.jpg", cover_names)
|
|
self.assertIn("song-1-song-artifact-1.jpg", cover_names)
|
|
self.assertEqual(1, linked_count)
|
|
|
|
def test_store_playlist_candidates_upserts_pool_and_links_playlists(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.models import PlaylistCandidate
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repository=repo)
|
|
|
|
pool_id = service.store_playlist_candidates(
|
|
platform="qq",
|
|
pool_kind="playlist_square",
|
|
pool_name="QQ 歌单广场",
|
|
candidates=[
|
|
PlaylistCandidate(
|
|
platform="qq",
|
|
pool_kind="playlist_square",
|
|
remote_id="7707261125",
|
|
name="甜度爆表",
|
|
url="https://y.qq.com/n/ryqq/playlist/7707261125",
|
|
),
|
|
PlaylistCandidate(
|
|
platform="qq",
|
|
pool_kind="playlist_square",
|
|
remote_id="7578943835",
|
|
name="丧系 Rap",
|
|
url="https://y.qq.com/n/ryqq/playlist/7578943835",
|
|
),
|
|
],
|
|
)
|
|
|
|
self.assertEqual(2, len(repo.list_playlists()))
|
|
self.assertEqual(2, len(repo.list_pool_playlist_links(pool_id)))
|
|
|
|
def test_store_playlist_songs_populates_songs_and_derived_artist_pool(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.models import PlaylistCandidate
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.services import CatalogSyncService
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
service = CatalogSyncService(repository=repo)
|
|
|
|
pool_id = service.store_playlist_candidates(
|
|
platform="netease",
|
|
pool_kind="playlist_square",
|
|
pool_name="网易云歌单广场",
|
|
candidates=[
|
|
PlaylistCandidate(
|
|
platform="netease",
|
|
pool_kind="playlist_square",
|
|
remote_id="7583298906",
|
|
name="华语清新收藏夹",
|
|
url="https://music.163.com/#/playlist?id=7583298906",
|
|
)
|
|
],
|
|
)
|
|
playlist_id = repo.list_playlists()[0]["id"]
|
|
|
|
artist_pool_id = service.store_playlist_songs(
|
|
playlist_id=playlist_id,
|
|
source_pool_id=pool_id,
|
|
song_infos=[
|
|
SimpleNamespace(
|
|
source="NeteaseMusicClient",
|
|
identifier="101",
|
|
song_name="第一首歌",
|
|
singers="歌手甲 / 歌手乙",
|
|
album="专辑 A",
|
|
ext="flac",
|
|
file_size_bytes=2048,
|
|
file_size="0.00 MB",
|
|
raw_data={"quality": "lossless", "search": {"ar": [{"name": "歌手甲"}, {"name": "歌手乙"}]}},
|
|
),
|
|
SimpleNamespace(
|
|
source="NeteaseMusicClient",
|
|
identifier="102",
|
|
song_name="第二首歌",
|
|
singers="歌手乙 / 歌手丙",
|
|
album="专辑 B",
|
|
ext="mp3",
|
|
file_size_bytes=1024,
|
|
file_size="0.00 MB",
|
|
raw_data={"quality": "standard", "search": {}},
|
|
),
|
|
],
|
|
)
|
|
|
|
self.assertEqual(2, repo.count_rows("songs"))
|
|
self.assertEqual(2, repo.count_rows("playlist_songs"))
|
|
self.assertEqual(1, repo.count_rows("artist_pools"))
|
|
self.assertEqual(3, repo.count_rows("artists"))
|
|
self.assertEqual(3, repo.count_rows("pool_artists"))
|
|
self.assertEqual(4, repo.count_rows("artist_songs"))
|
|
self.assertIsInstance(artist_pool_id, int)
|
|
|
|
def test_download_planner_skips_song_with_existing_local_file(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.catalogsync.downloader import DownloadPlanner
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
|
|
song_a_id = repo.upsert_song(
|
|
CatalogSong(platform="qq", remote_song_id="song-a", name="已下载歌曲", ext="flac", file_size_bytes=100)
|
|
)
|
|
song_b_id = repo.upsert_song(
|
|
CatalogSong(platform="qq", remote_song_id="song-b", name="待下载歌曲", ext="mp3", file_size_bytes=80)
|
|
)
|
|
backend_id = repo.get_default_backend_id()
|
|
repo.record_local_file(
|
|
song_id=song_a_id,
|
|
backend_id=backend_id,
|
|
relative_path="qq/已下载歌曲.flac",
|
|
file_size_bytes=100,
|
|
ext="flac",
|
|
quality_label="lossless",
|
|
)
|
|
|
|
planner = DownloadPlanner(repository=repo)
|
|
pending = planner.build_download_queue()
|
|
|
|
self.assertEqual([song_b_id], [item["song_id"] for item in pending])
|
|
|
|
def test_download_planner_can_limit_queue_to_specific_playlists(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import DownloadPlanner
|
|
from musicdl.catalogsync.models import CatalogSong, PlaylistCandidate
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
initialize_database(db_path).close()
|
|
repo = CatalogRepository(db_path)
|
|
|
|
playlist_a = repo.upsert_playlist(
|
|
PlaylistCandidate(
|
|
platform="qq",
|
|
pool_kind="manual_file",
|
|
remote_id="7707261125",
|
|
name="Playlist A",
|
|
url="https://y.qq.com/n/ryqq/playlist/7707261125",
|
|
)
|
|
)
|
|
playlist_b = repo.upsert_playlist(
|
|
PlaylistCandidate(
|
|
platform="qq",
|
|
pool_kind="manual_file",
|
|
remote_id="7578943835",
|
|
name="Playlist B",
|
|
url="https://y.qq.com/n/ryqq/playlist/7578943835",
|
|
)
|
|
)
|
|
song_a = repo.upsert_song(CatalogSong(platform="qq", remote_song_id="song-a", name="A"))
|
|
song_b = repo.upsert_song(CatalogSong(platform="qq", remote_song_id="song-b", name="B"))
|
|
repo.link_playlist_song(playlist_a, song_a, 1)
|
|
repo.link_playlist_song(playlist_b, song_b, 1)
|
|
|
|
planner = DownloadPlanner(repo)
|
|
queue = planner.build_download_queue(playlist_ids=[playlist_a])
|
|
|
|
self.assertEqual([song_a], [item["song_id"] for item in queue])
|
|
|
|
def test_catalog_downloader_records_alternate_backend_when_space_switches_root(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].work_dir) / "song-c.mp3"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library-a"
|
|
alternate_root = Path(tmpdir) / "library-b"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-c",
|
|
name="闈炲父缁х画涓嬭浇",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
quality_label="standard",
|
|
metadata={"snapshot": {"identifier": "song-c"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
return_value=SimpleNamespace(singers="Singer A / Singer B"),
|
|
):
|
|
with patch.object(downloader, "ensure_space", return_value=alternate_root):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
downloaded_count = downloader.download_pending(library_root=library_root, limit=1)
|
|
|
|
location = repo._fetchone(
|
|
"""
|
|
SELECT sb.base_path, sb.name, fl.locator, fl.absolute_path
|
|
FROM file_locations fl
|
|
JOIN file_assets fa ON fa.id = fl.file_asset_id
|
|
JOIN storage_backends sb ON sb.id = fl.backend_id
|
|
WHERE fa.song_id = ?
|
|
ORDER BY fl.id DESC
|
|
LIMIT 1
|
|
""",
|
|
(song_id,),
|
|
)
|
|
|
|
self.assertEqual(1, downloaded_count)
|
|
self.assertEqual(str(alternate_root.resolve()), location["base_path"])
|
|
self.assertTrue(str(location["absolute_path"]).startswith(str(alternate_root.resolve())))
|
|
self.assertEqual("qq/Singer A/song-c.mp3", location["locator"])
|
|
|
|
def test_catalog_downloader_prompts_once_and_reuses_new_root_with_multiple_workers(self):
|
|
from collections import namedtuple
|
|
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].work_dir) / f"{song_infos[0].identifier}.mp3"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
DiskUsage = namedtuple("DiskUsage", ["total", "used", "free"])
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library-a"
|
|
alternate_root = Path(tmpdir) / "library-b"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-a",
|
|
name="Song A",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
metadata={"snapshot": {"identifier": "song-a"}},
|
|
)
|
|
)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-b",
|
|
name="Song B",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
metadata={"snapshot": {"identifier": "song-b"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo, worker_count=2)
|
|
|
|
def fake_disk_usage(path):
|
|
resolved = Path(path).resolve()
|
|
if resolved == library_root.resolve():
|
|
return DiskUsage(total=1000, used=960, free=40)
|
|
return DiskUsage(total=1000, used=100, free=900)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
side_effect=[
|
|
SimpleNamespace(identifier="song-a", singers="Singer A / Singer B"),
|
|
SimpleNamespace(identifier="song-b", singers="Singer A / Singer B"),
|
|
],
|
|
):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
with patch("musicdl.catalogsync.downloader.shutil.disk_usage", side_effect=fake_disk_usage):
|
|
with patch("builtins.input", return_value=str(alternate_root)) as mocked_input:
|
|
downloaded_count = downloader.download_pending(library_root=library_root, limit=2)
|
|
|
|
locations = repo._fetchall(
|
|
"""
|
|
SELECT absolute_path
|
|
FROM file_locations
|
|
ORDER BY id ASC
|
|
"""
|
|
)
|
|
|
|
self.assertEqual(2, downloaded_count)
|
|
self.assertEqual(1, mocked_input.call_count)
|
|
self.assertEqual(2, len(locations))
|
|
self.assertTrue(all(str(row["absolute_path"]).startswith(str(alternate_root.resolve())) for row in locations))
|
|
|
|
def test_catalog_downloader_records_platform_first_artist_locator(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].work_dir) / "song-c.mp3"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-c",
|
|
name="Song C",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
metadata={"snapshot": {"identifier": "song-c"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
return_value=SimpleNamespace(singers="Singer A / Singer B"),
|
|
):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
downloader.download_pending(library_root=library_root, limit=1)
|
|
|
|
location = repo._fetchone("SELECT locator FROM file_locations ORDER BY id DESC LIMIT 1")
|
|
|
|
self.assertEqual("qq/Singer A/song-c.mp3", location["locator"])
|
|
|
|
def test_catalog_downloader_refreshes_song_info_before_download(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
class FakeClient:
|
|
def __init__(self):
|
|
self.downloaded_identifiers = []
|
|
|
|
def _parsewithofficialapiv1(self, search_result, request_overrides=None, song_info_flac=None):
|
|
return SimpleNamespace(
|
|
identifier="fresh-song",
|
|
singers="Singer A / Singer B",
|
|
song_name="Fresh Song",
|
|
ext="mp3",
|
|
with_valid_download_url=True,
|
|
)
|
|
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
self.downloaded_identifiers.append(song_infos[0].identifier)
|
|
save_path = Path(song_infos[0].work_dir) / f"{song_infos[0].identifier}.mp3"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fresh-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-c",
|
|
name="Song C",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
metadata={"snapshot": {"identifier": "song-c"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
fake_client = FakeClient()
|
|
stale_song_info = SimpleNamespace(
|
|
identifier="song-c",
|
|
singers="Singer A / Singer B",
|
|
song_name="Song C",
|
|
raw_data={"search": {"songmid": "song-c"}},
|
|
with_valid_download_url=False,
|
|
)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
return_value=stale_song_info,
|
|
):
|
|
with patch.object(downloader, "get_client", return_value=fake_client):
|
|
downloaded_count = downloader.download_pending(library_root=library_root, limit=1)
|
|
|
|
location = repo._fetchone("SELECT locator FROM file_locations ORDER BY id DESC LIMIT 1")
|
|
|
|
self.assertEqual(1, downloaded_count)
|
|
self.assertEqual(["fresh-song"], fake_client.downloaded_identifiers)
|
|
self.assertEqual("qq/Singer A/fresh-song.mp3", location["locator"])
|
|
|
|
def test_catalog_downloader_uses_resolved_cross_platform_source_for_download(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class FakeClient:
|
|
def __init__(self):
|
|
self.downloaded_identifiers = []
|
|
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False, request_overrides=None):
|
|
self.downloaded_identifiers.append(song_infos[0].identifier)
|
|
save_path = Path(song_infos[0].work_dir) / f"{song_infos[0].identifier}.{song_infos[0].ext}"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-c",
|
|
name="Song C",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=1024,
|
|
quality_label="standard",
|
|
metadata={"snapshot": {"identifier": "song-c"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
netease_client = FakeClient()
|
|
qq_client = FakeClient()
|
|
stale_song_info = SongInfo(
|
|
source="NeteaseMusicClient",
|
|
identifier="song-c",
|
|
song_name="Song C",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
raw_data={"search": {"id": "song-c"}},
|
|
download_url=None,
|
|
download_url_status={},
|
|
)
|
|
resolved_song_info = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="qq-song-c",
|
|
song_name="Song C",
|
|
singers="Singer A / Singer B",
|
|
ext="flac",
|
|
file_size_bytes=4096,
|
|
file_size="4.00 MB",
|
|
raw_data={"quality": "lossless"},
|
|
download_url="https://example.com/song-c.flac",
|
|
download_url_status={"ok": True},
|
|
)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
return_value=stale_song_info,
|
|
):
|
|
with patch.object(
|
|
downloader,
|
|
"resolve_song_info_for_download",
|
|
return_value=resolved_song_info,
|
|
) as resolver:
|
|
with patch.object(
|
|
downloader,
|
|
"get_client",
|
|
side_effect=lambda platform: {
|
|
"netease": netease_client,
|
|
"qq": qq_client,
|
|
}[platform],
|
|
):
|
|
downloaded_count = downloader.download_pending(
|
|
library_root=library_root,
|
|
limit=1,
|
|
download_sources=["qq", "netease"],
|
|
)
|
|
|
|
location = repo._fetchone("SELECT locator FROM file_locations ORDER BY id DESC LIMIT 1")
|
|
asset = repo._fetchone("SELECT ext, quality_label FROM file_assets ORDER BY id DESC LIMIT 1")
|
|
|
|
self.assertEqual(1, downloaded_count)
|
|
self.assertEqual([], netease_client.downloaded_identifiers)
|
|
self.assertEqual(["qq-song-c"], qq_client.downloaded_identifiers)
|
|
self.assertEqual(["qq", "netease"], resolver.call_args.kwargs["download_sources"])
|
|
self.assertEqual("qq/Singer A/qq-song-c.flac", location["locator"])
|
|
self.assertEqual("flac", asset["ext"])
|
|
self.assertEqual("lossless", asset["quality_label"])
|
|
|
|
def test_catalog_downloader_falls_back_to_db_singers_when_snapshot_singers_is_null_text(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].work_dir) / "song-null.mp3"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-null",
|
|
name="Song Null",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
metadata={"snapshot": {"identifier": "song-null"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
return_value=SimpleNamespace(singers="NULL"),
|
|
):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
downloader.download_pending(library_root=library_root, limit=1)
|
|
|
|
location = repo._fetchone("SELECT locator FROM file_locations ORDER BY id DESC LIMIT 1")
|
|
|
|
self.assertEqual("qq/Singer A/song-null.mp3", location["locator"])
|
|
|
|
def test_catalog_downloader_falls_back_to_db_singers_when_snapshot_singers_is_non_string(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].work_dir) / "song-non-string.mp3"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-non-string",
|
|
name="Song Non String",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
metadata={"snapshot": {"identifier": "song-non-string"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
return_value=SimpleNamespace(singers=123),
|
|
):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
downloader.download_pending(library_root=library_root, limit=1)
|
|
|
|
location = repo._fetchone("SELECT locator FROM file_locations ORDER BY id DESC LIMIT 1")
|
|
|
|
self.assertEqual("qq/Singer A/song-non-string.mp3", location["locator"])
|
|
|
|
def test_catalog_downloader_falls_back_to_db_singers_when_snapshot_singers_is_falsy_non_string(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].work_dir) / "song-falsy-non-string.mp3"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-falsy-non-string",
|
|
name="Song Falsy Non String",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
metadata={"snapshot": {"identifier": "song-falsy-non-string"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
return_value=SimpleNamespace(singers=0),
|
|
):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
downloader.download_pending(library_root=library_root, limit=1)
|
|
|
|
location = repo._fetchone("SELECT locator FROM file_locations ORDER BY id DESC LIMIT 1")
|
|
|
|
self.assertEqual("qq/Singer A/song-falsy-non-string.mp3", location["locator"])
|
|
|
|
def test_catalog_downloader_rebuilds_save_path_after_switching_work_dir(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].save_path)
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-c",
|
|
name="Song C",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
file_size_bytes=80,
|
|
metadata={"snapshot": {"identifier": "song-c"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
stale_path = Path(tmpdir) / "legacy" / "song-c.mp3"
|
|
song_info = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="song-c",
|
|
song_name="Song C",
|
|
singers="Singer A / Singer B",
|
|
ext="mp3",
|
|
work_dir=str(stale_path.parent),
|
|
)
|
|
song_info._save_path = str(stale_path)
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=song_info):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
downloaded_count = downloader.download_pending(library_root=library_root, limit=1)
|
|
|
|
location = repo._fetchone("SELECT locator, absolute_path FROM file_locations ORDER BY id DESC LIMIT 1")
|
|
|
|
self.assertEqual(1, downloaded_count)
|
|
self.assertTrue(str(location["locator"]).startswith("qq/Singer A/"))
|
|
self.assertTrue(str(location["absolute_path"]).startswith(str(library_root.resolve())))
|
|
|
|
def test_catalog_downloader_uses_unknown_artist_fallback_directory(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].work_dir) / "song-a.flac"
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-a",
|
|
name="Song A",
|
|
singers="NULL",
|
|
ext="flac",
|
|
file_size_bytes=100,
|
|
metadata={"snapshot": {"identifier": "song-a"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
|
|
with patch(
|
|
"musicdl.catalogsync.downloader.deserialize_song_info",
|
|
return_value=SimpleNamespace(singers="NULL"),
|
|
):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
downloader.download_pending(library_root=library_root, limit=1)
|
|
|
|
location = repo._fetchone("SELECT locator FROM file_locations ORDER BY id DESC LIMIT 1")
|
|
|
|
self.assertEqual("netease/Unknown Artist/song-a.flac", location["locator"])
|
|
|
|
def test_catalog_downloader_reports_real_time_progress_to_worker_callback(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class SlowWritingClient:
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
song_info = song_infos[0]
|
|
save_path = Path(song_info.save_path)
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(save_path, "wb") as file_obj:
|
|
for chunk in (b"a" * 8, b"b" * 8, b"c" * 8):
|
|
file_obj.write(chunk)
|
|
file_obj.flush()
|
|
time.sleep(0.05)
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-progress",
|
|
name="Song Progress",
|
|
singers="Singer Progress",
|
|
ext="mp3",
|
|
file_size_bytes=24,
|
|
metadata={"snapshot": {"identifier": "song-progress"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
song_info = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="song-progress",
|
|
song_name="Song Progress",
|
|
singers="Singer Progress",
|
|
ext="mp3",
|
|
file_size_bytes=24,
|
|
download_url="https://example.com/song-progress.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
worker_updates: list[dict[str, object]] = []
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=song_info):
|
|
with patch.object(downloader, "get_client", return_value=SlowWritingClient()):
|
|
ok = downloader.download_song_row(
|
|
row={
|
|
"id": song_id,
|
|
"playlist_id": 123,
|
|
"platform": "qq",
|
|
"name": "Song Progress",
|
|
"singers": "Singer Progress",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 24,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-progress"}}',
|
|
},
|
|
library_root=library_root,
|
|
worker_callback=lambda **state: worker_updates.append(dict(state)),
|
|
)
|
|
|
|
self.assertTrue(ok)
|
|
speed_updates = [
|
|
state
|
|
for state in worker_updates
|
|
if float(state.get("speed_bytes_per_sec") or 0) > 0 and int(state.get("downloaded_bytes") or 0) > 0
|
|
]
|
|
self.assertTrue(speed_updates, worker_updates)
|
|
self.assertEqual(123, speed_updates[-1]["current_playlist_id"])
|
|
self.assertEqual(song_id, speed_updates[-1]["current_song_id"])
|
|
self.assertGreaterEqual(int(speed_updates[-1]["progress_percent"] or 0), 33)
|
|
self.assertIn("MB", str(speed_updates[-1].get("last_progress_text") or ""))
|
|
|
|
def test_catalog_downloader_reports_progress_when_client_switches_save_path_during_download(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
from musicdl.modules.utils.misc import shortenpathsinsonginfos
|
|
|
|
long_song_name = f"Song Progress {'X' * 220}"
|
|
switched_paths: list[tuple[str, str]] = []
|
|
|
|
class PathSwitchingClient:
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
song_info = song_infos[0]
|
|
original_path = str(song_info.save_path)
|
|
shortenpathsinsonginfos(song_infos)
|
|
switched_path = Path(song_info.save_path)
|
|
switched_paths.append((original_path, str(switched_path)))
|
|
switched_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(switched_path, "wb") as file_obj:
|
|
for chunk in (b"a" * 8, b"b" * 8, b"c" * 8):
|
|
file_obj.write(chunk)
|
|
file_obj.flush()
|
|
time.sleep(0.05)
|
|
return [SimpleNamespace(save_path=str(switched_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="qq",
|
|
remote_song_id="song-progress-switched",
|
|
name=long_song_name,
|
|
singers="Singer Progress",
|
|
ext="mp3",
|
|
file_size_bytes=24,
|
|
metadata={"snapshot": {"identifier": "song-progress-switched"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
song_info = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="song-progress-switched",
|
|
song_name=long_song_name,
|
|
singers="Singer Progress",
|
|
ext="mp3",
|
|
file_size_bytes=24,
|
|
download_url="https://example.com/song-progress-switched.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
self.assertGreater(len(song_info.save_path), 240)
|
|
song_info._save_path = None
|
|
worker_updates: list[dict[str, object]] = []
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=song_info):
|
|
with patch.object(downloader, "get_client", return_value=PathSwitchingClient()):
|
|
ok = downloader.download_song_row(
|
|
row={
|
|
"id": song_id,
|
|
"playlist_id": 456,
|
|
"platform": "qq",
|
|
"name": long_song_name,
|
|
"singers": "Singer Progress",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 24,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-progress-switched"}}',
|
|
},
|
|
library_root=library_root,
|
|
worker_callback=lambda **state: worker_updates.append(dict(state)),
|
|
)
|
|
|
|
self.assertTrue(ok)
|
|
self.assertTrue(switched_paths)
|
|
self.assertLessEqual(len(switched_paths[0][1]), 240)
|
|
speed_updates = [
|
|
state
|
|
for state in worker_updates
|
|
if float(state.get("speed_bytes_per_sec") or 0) > 0 and int(state.get("downloaded_bytes") or 0) > 0
|
|
]
|
|
self.assertTrue(speed_updates, worker_updates)
|
|
self.assertEqual(456, speed_updates[-1]["current_playlist_id"])
|
|
self.assertEqual(song_id, speed_updates[-1]["current_song_id"])
|
|
self.assertGreaterEqual(int(speed_updates[-1]["progress_percent"] or 0), 33)
|
|
|
|
def test_catalog_downloader_reports_resolve_activity_to_worker_callback(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].save_path)
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-resolve",
|
|
name="Song Resolve",
|
|
singers="Singer Resolve",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={"snapshot": {"identifier": "song-resolve"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
snapshot_song = SongInfo(
|
|
source="NeteaseMusicClient",
|
|
identifier="song-resolve",
|
|
song_name="Song Resolve",
|
|
singers="Singer Resolve",
|
|
ext="mp3",
|
|
download_url=None,
|
|
download_url_status={},
|
|
)
|
|
resolved_song = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="qq-song-resolve",
|
|
song_name="Song Resolve",
|
|
singers="Singer Resolve",
|
|
ext="mp3",
|
|
download_url="https://example.com/song-resolve.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
worker_updates: list[dict[str, object]] = []
|
|
|
|
def fake_resolve_song_info(*, row, snapshot_song_info, download_sources=None, progress_callback=None):
|
|
if progress_callback is not None:
|
|
progress_callback("resolving source qq (1/2)")
|
|
progress_callback("resolving source kuwo (2/2)")
|
|
return resolved_song
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=snapshot_song):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
with patch.object(downloader._resolver, "resolve_song_info", side_effect=fake_resolve_song_info):
|
|
ok = downloader.download_song_row(
|
|
row={
|
|
"id": song_id,
|
|
"playlist_id": 789,
|
|
"platform": "netease",
|
|
"name": "Song Resolve",
|
|
"singers": "Singer Resolve",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 10,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-resolve"}}',
|
|
},
|
|
library_root=library_root,
|
|
worker_callback=lambda **state: worker_updates.append(dict(state)),
|
|
)
|
|
|
|
self.assertTrue(ok)
|
|
resolve_updates = [
|
|
state for state in worker_updates if "resolving source" in str(state.get("last_progress_text") or "")
|
|
]
|
|
self.assertEqual(
|
|
["resolving source qq (1/2)", "resolving source kuwo (2/2)"],
|
|
[str(state["last_progress_text"]) for state in resolve_updates],
|
|
)
|
|
self.assertTrue(any(int(state.get("downloaded_bytes") or 0) > 0 for state in worker_updates))
|
|
|
|
def test_catalog_downloader_resolve_song_row_returns_resolved_payload(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-resolve-api",
|
|
name="Song Resolve",
|
|
singers="Singer Resolve",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={"snapshot": {"identifier": "song-resolve-api"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
snapshot_song = SongInfo(
|
|
source="NeteaseMusicClient",
|
|
identifier="song-resolve-api",
|
|
song_name="Song Resolve",
|
|
singers="Singer Resolve",
|
|
ext="mp3",
|
|
download_url=None,
|
|
download_url_status={},
|
|
)
|
|
resolved_song = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="qq-song-resolve-api",
|
|
song_name="Song Resolve",
|
|
singers="Singer Resolve",
|
|
ext="mp3",
|
|
download_url="https://example.com/song-resolve-api.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
row = {
|
|
"id": song_id,
|
|
"playlist_id": 789,
|
|
"platform": "netease",
|
|
"name": "Song Resolve",
|
|
"singers": "Singer Resolve",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 10,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-resolve-api"}}',
|
|
}
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=snapshot_song):
|
|
with patch.object(downloader._resolver, "resolve_song_info", return_value=resolved_song):
|
|
payload = downloader.resolve_song_row(
|
|
row=row,
|
|
library_root=library_root,
|
|
download_sources=["qq", "kuwo"],
|
|
)
|
|
|
|
self.assertIsNotNone(payload)
|
|
self.assertEqual(song_id, payload.row["id"])
|
|
self.assertEqual("Song Resolve / Singer Resolve", payload.display_text)
|
|
self.assertEqual("QQMusicClient", payload.resolved_song_info.source)
|
|
|
|
def test_catalog_downloader_download_resolved_song_reports_progress_and_records_file(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class SlowWritingClient:
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
song_info = song_infos[0]
|
|
save_path = Path(song_info.save_path)
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(save_path, "wb") as file_obj:
|
|
for chunk in (b"a" * 8, b"b" * 8, b"c" * 8):
|
|
file_obj.write(chunk)
|
|
file_obj.flush()
|
|
time.sleep(0.05)
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-resolved-download",
|
|
name="Song Resolved Download",
|
|
singers="Singer Resolved",
|
|
ext="mp3",
|
|
file_size_bytes=24,
|
|
metadata={"snapshot": {"identifier": "song-resolved-download"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
snapshot_song = SongInfo(
|
|
source="NeteaseMusicClient",
|
|
identifier="song-resolved-download",
|
|
song_name="Song Resolved Download",
|
|
singers="Singer Resolved",
|
|
ext="mp3",
|
|
download_url=None,
|
|
download_url_status={},
|
|
)
|
|
resolved_song = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="qq-song-resolved-download",
|
|
song_name="Song Resolved Download",
|
|
singers="Singer Resolved",
|
|
ext="mp3",
|
|
file_size_bytes=24,
|
|
download_url="https://example.com/song-resolved-download.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
row = {
|
|
"id": song_id,
|
|
"playlist_id": 123,
|
|
"platform": "netease",
|
|
"name": "Song Resolved Download",
|
|
"singers": "Singer Resolved",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 24,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-resolved-download"}}',
|
|
}
|
|
worker_updates: list[dict[str, object]] = []
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=snapshot_song):
|
|
with patch.object(downloader, "get_client", return_value=SlowWritingClient()):
|
|
with patch.object(downloader._resolver, "resolve_song_info", return_value=resolved_song):
|
|
resolved_payload = downloader.resolve_song_row(
|
|
row=row,
|
|
library_root=library_root,
|
|
download_sources=["qq", "kuwo"],
|
|
worker_callback=lambda **state: worker_updates.append(dict(state)),
|
|
)
|
|
self.assertIsNotNone(resolved_payload)
|
|
ok = downloader.download_resolved_song(
|
|
resolved_payload=resolved_payload,
|
|
worker_callback=lambda **state: worker_updates.append(dict(state)),
|
|
)
|
|
self.assertTrue(ok)
|
|
self.assertTrue(any(int(state.get("downloaded_bytes") or 0) > 0 for state in worker_updates), worker_updates)
|
|
self.assertTrue(repo.song_has_active_local_file(song_id))
|
|
|
|
def test_catalog_downloader_download_song_row_remains_a_compatibility_wrapper(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].save_path)
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-wrapper",
|
|
name="Song Wrapper",
|
|
singers="Singer Wrapper",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={"snapshot": {"identifier": "song-wrapper"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
snapshot_song = SongInfo(
|
|
source="NeteaseMusicClient",
|
|
identifier="song-wrapper",
|
|
song_name="Song Wrapper",
|
|
singers="Singer Wrapper",
|
|
ext="mp3",
|
|
download_url=None,
|
|
download_url_status={},
|
|
)
|
|
resolved_song = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="qq-song-wrapper",
|
|
song_name="Song Wrapper",
|
|
singers="Singer Wrapper",
|
|
ext="mp3",
|
|
download_url="https://example.com/song-wrapper.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
row = {
|
|
"id": song_id,
|
|
"playlist_id": 456,
|
|
"platform": "netease",
|
|
"name": "Song Wrapper",
|
|
"singers": "Singer Wrapper",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 10,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-wrapper"}}',
|
|
}
|
|
worker_updates: list[dict[str, object]] = []
|
|
|
|
def fake_resolve_song_info(*, row, snapshot_song_info, download_sources=None, progress_callback=None):
|
|
if progress_callback is not None:
|
|
progress_callback("resolving source qq (1/1)")
|
|
return resolved_song
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=snapshot_song):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
with patch.object(downloader._resolver, "resolve_song_info", side_effect=fake_resolve_song_info):
|
|
ok = downloader.download_song_row(
|
|
row=row,
|
|
library_root=library_root,
|
|
worker_callback=lambda **state: worker_updates.append(dict(state)),
|
|
)
|
|
|
|
self.assertTrue(ok)
|
|
self.assertTrue(
|
|
any("resolving source" in str(state.get("last_progress_text") or "") for state in worker_updates),
|
|
worker_updates,
|
|
)
|
|
self.assertTrue(
|
|
any("starting download via" in str(state.get("last_progress_text") or "") for state in worker_updates),
|
|
worker_updates,
|
|
)
|
|
|
|
|
|
def test_catalog_downloader_download_resolved_song_saves_lyrics_file(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].save_path)
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-lyrics-save",
|
|
name="Song Lyrics Save",
|
|
singers="Singer Lyrics",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={"snapshot": {"identifier": "song-lyrics-save"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
snapshot_song = SongInfo(
|
|
source="NeteaseMusicClient",
|
|
identifier="song-lyrics-save",
|
|
song_name="Song Lyrics Save",
|
|
singers="Singer Lyrics",
|
|
ext="mp3",
|
|
download_url=None,
|
|
download_url_status={},
|
|
)
|
|
resolved_song = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="qq-song-lyrics-save",
|
|
song_name="Song Lyrics Save",
|
|
singers="Singer Lyrics",
|
|
ext="mp3",
|
|
lyric="[00:00.00]hello world",
|
|
download_url="https://example.com/song-lyrics-save.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
row = {
|
|
"id": song_id,
|
|
"playlist_id": 123,
|
|
"platform": "netease",
|
|
"name": "Song Lyrics Save",
|
|
"singers": "Singer Lyrics",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 10,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-lyrics-save"}}',
|
|
}
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=snapshot_song):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
with patch.object(downloader._resolver, "resolve_song_info", return_value=resolved_song):
|
|
resolved_payload = downloader.resolve_song_row(row=row, library_root=library_root)
|
|
self.assertIsNotNone(resolved_payload)
|
|
ok = downloader.download_resolved_song(resolved_payload=resolved_payload)
|
|
|
|
self.assertTrue(ok)
|
|
lrc_path = Path(resolved_payload.resolved_song_info.save_path).with_suffix(".lrc")
|
|
self.assertTrue(lrc_path.exists())
|
|
self.assertEqual("[00:00.00]hello world\n", lrc_path.read_text(encoding="utf-8"))
|
|
|
|
def test_catalog_downloader_download_resolved_song_does_not_overwrite_existing_lyrics_by_default(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].save_path)
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-lyrics-overwrite",
|
|
name="Song Lyrics Overwrite",
|
|
singers="Singer Lyrics",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={"snapshot": {"identifier": "song-lyrics-overwrite"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
snapshot_song = SongInfo(
|
|
source="NeteaseMusicClient",
|
|
identifier="song-lyrics-overwrite",
|
|
song_name="Song Lyrics Overwrite",
|
|
singers="Singer Lyrics",
|
|
ext="mp3",
|
|
download_url=None,
|
|
download_url_status={},
|
|
)
|
|
resolved_song = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="qq-song-lyrics-overwrite",
|
|
song_name="Song Lyrics Overwrite",
|
|
singers="Singer Lyrics",
|
|
ext="mp3",
|
|
lyric="[00:00.00]new lyric",
|
|
download_url="https://example.com/song-lyrics-overwrite.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
row = {
|
|
"id": song_id,
|
|
"playlist_id": 123,
|
|
"platform": "netease",
|
|
"name": "Song Lyrics Overwrite",
|
|
"singers": "Singer Lyrics",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 10,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-lyrics-overwrite"}}',
|
|
}
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=snapshot_song):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
with patch.object(downloader._resolver, "resolve_song_info", return_value=resolved_song):
|
|
resolved_payload = downloader.resolve_song_row(row=row, library_root=library_root)
|
|
self.assertIsNotNone(resolved_payload)
|
|
lrc_path = Path(resolved_payload.resolved_song_info.save_path).with_suffix(".lrc")
|
|
lrc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
lrc_path.write_text("old lyric\n", encoding="utf-8")
|
|
ok = downloader.download_resolved_song(resolved_payload=resolved_payload)
|
|
|
|
self.assertTrue(ok)
|
|
self.assertEqual("old lyric\n", lrc_path.read_text(encoding="utf-8"))
|
|
|
|
def test_catalog_downloader_download_resolved_song_looks_up_lyrics_when_missing(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
from musicdl.modules.utils.data import SongInfo
|
|
|
|
class FakeClient:
|
|
def download(self, song_infos, num_threadings=1, request_overrides=None, auto_supplement_song=False):
|
|
save_path = Path(song_infos[0].save_path)
|
|
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
save_path.write_bytes(b"fake-audio")
|
|
return [SimpleNamespace(save_path=str(save_path))]
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-lyrics-search",
|
|
name="Song Lyrics Search",
|
|
singers="Singer Search",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={"snapshot": {"identifier": "song-lyrics-search"}},
|
|
)
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
snapshot_song = SongInfo(
|
|
source="NeteaseMusicClient",
|
|
identifier="song-lyrics-search",
|
|
song_name="Song Lyrics Search",
|
|
singers="Singer Search",
|
|
ext="mp3",
|
|
download_url=None,
|
|
download_url_status={},
|
|
)
|
|
resolved_song = SongInfo(
|
|
source="QQMusicClient",
|
|
identifier="qq-song-lyrics-search",
|
|
song_name="Song Lyrics Search",
|
|
singers="Singer Search",
|
|
ext="mp3",
|
|
lyric="NULL",
|
|
download_url="https://example.com/song-lyrics-search.mp3",
|
|
download_url_status={"ok": True},
|
|
)
|
|
row = {
|
|
"id": song_id,
|
|
"playlist_id": 123,
|
|
"platform": "netease",
|
|
"name": "Song Lyrics Search",
|
|
"singers": "Singer Search",
|
|
"ext": "mp3",
|
|
"file_size_bytes": 10,
|
|
"metadata_json": '{"snapshot":{"identifier":"song-lyrics-search"}}',
|
|
}
|
|
|
|
with patch("musicdl.catalogsync.downloader.deserialize_song_info", return_value=snapshot_song):
|
|
with patch.object(downloader, "get_client", return_value=FakeClient()):
|
|
with patch.object(downloader._resolver, "resolve_song_info", return_value=resolved_song):
|
|
with patch("musicdl.catalogsync.downloader.LyricSearchClient.search", return_value=({}, "[00:00.00]searched lyric")) as search_mock:
|
|
resolved_payload = downloader.resolve_song_row(row=row, library_root=library_root)
|
|
self.assertIsNotNone(resolved_payload)
|
|
ok = downloader.download_resolved_song(resolved_payload=resolved_payload)
|
|
|
|
self.assertTrue(ok)
|
|
search_mock.assert_called_once()
|
|
lrc_path = Path(resolved_payload.resolved_song_info.save_path).with_suffix(".lrc")
|
|
self.assertEqual("[00:00.00]searched lyric\n", lrc_path.read_text(encoding="utf-8"))
|
|
|
|
def test_catalog_downloader_sync_local_lyrics_updates_existing_downloaded_song_files(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-local-lyrics",
|
|
name="Song Local Lyrics",
|
|
singers="Singer Local",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={
|
|
"snapshot": {
|
|
"identifier": "song-local-lyrics",
|
|
"song_name": "Song Local Lyrics",
|
|
"singers": "Singer Local",
|
|
"ext": "mp3",
|
|
}
|
|
},
|
|
)
|
|
)
|
|
backend_id = repo.ensure_local_backend(library_root, name="default-local", is_default=True)
|
|
relative_path = "netease/Singer Local/Song Local Lyrics - song-local-lyrics.mp3"
|
|
absolute_path = library_root / relative_path
|
|
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
|
absolute_path.write_bytes(b"fake-audio")
|
|
repo.record_local_file(
|
|
song_id=song_id,
|
|
backend_id=backend_id,
|
|
relative_path=relative_path.replace("\\", "/"),
|
|
file_size_bytes=10,
|
|
ext="mp3",
|
|
quality_label=None,
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
|
|
with patch("musicdl.catalogsync.downloader.LyricSearchClient.search", return_value=({}, "[00:00.00]batch lyric")) as search_mock:
|
|
summary = downloader.sync_local_lyrics(limit=10, overwrite_lyrics=False)
|
|
|
|
self.assertEqual(1, summary["processed"])
|
|
self.assertEqual(1, summary["saved"])
|
|
self.assertEqual(0, summary["failed"])
|
|
search_mock.assert_called_once()
|
|
lrc_path = absolute_path.with_suffix(".lrc")
|
|
self.assertEqual("[00:00.00]batch lyric\n", lrc_path.read_text(encoding="utf-8"))
|
|
|
|
def test_catalog_downloader_sync_local_lyrics_does_not_hang_on_lyric_search_timeout(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id="song-local-timeout",
|
|
name="Song Local Timeout",
|
|
singers="Singer Timeout",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={
|
|
"snapshot": {
|
|
"identifier": "song-local-timeout",
|
|
"song_name": "Song Local Timeout",
|
|
"singers": "Singer Timeout",
|
|
"ext": "mp3",
|
|
}
|
|
},
|
|
)
|
|
)
|
|
backend_id = repo.ensure_local_backend(library_root, name="default-local", is_default=True)
|
|
relative_path = "netease/Singer Timeout/Song Local Timeout - song-local-timeout.mp3"
|
|
absolute_path = library_root / relative_path
|
|
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
|
absolute_path.write_bytes(b"fake-audio")
|
|
repo.record_local_file(
|
|
song_id=song_id,
|
|
backend_id=backend_id,
|
|
relative_path=relative_path.replace("\\", "/"),
|
|
file_size_bytes=10,
|
|
ext="mp3",
|
|
quality_label=None,
|
|
)
|
|
downloader = CatalogDownloader(repository=repo)
|
|
|
|
def blocked_search(*args, **kwargs):
|
|
time.sleep(0.2)
|
|
return {}, "NULL"
|
|
|
|
with patch.object(downloader, "_lyric_search_timeout_seconds", 0.05):
|
|
with patch("musicdl.catalogsync.downloader.LyricSearchClient.search", side_effect=blocked_search):
|
|
summary = downloader.sync_local_lyrics(limit=10, overwrite_lyrics=False)
|
|
|
|
self.assertEqual(1, summary["processed"])
|
|
self.assertEqual(0, summary["saved"])
|
|
self.assertEqual(1, summary["skipped"])
|
|
self.assertEqual(0, summary["failed"])
|
|
self.assertFalse(absolute_path.with_suffix(".lrc").exists())
|
|
|
|
def test_catalog_downloader_sync_local_lyrics_runs_workers_concurrently_and_reports_progress(self):
|
|
from musicdl.catalogsync.db import initialize_database
|
|
from musicdl.catalogsync.downloader import CatalogDownloader
|
|
from musicdl.catalogsync.models import CatalogSong
|
|
from musicdl.catalogsync.repository import CatalogRepository
|
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
|
db_path = Path(tmpdir) / "catalogsync.db"
|
|
library_root = Path(tmpdir) / "library"
|
|
initialize_database(db_path, default_library_root=library_root).close()
|
|
repo = CatalogRepository(db_path)
|
|
backend_id = repo.ensure_local_backend(library_root, name="default-local", is_default=True)
|
|
song_rows = []
|
|
for index in range(3):
|
|
identifier = f"song-local-progress-{index}"
|
|
song_id = repo.upsert_song(
|
|
CatalogSong(
|
|
platform="netease",
|
|
remote_song_id=identifier,
|
|
name=f"Song Local Progress {index}",
|
|
singers="Singer Progress",
|
|
ext="mp3",
|
|
file_size_bytes=10,
|
|
metadata={
|
|
"snapshot": {
|
|
"identifier": identifier,
|
|
"song_name": f"Song Local Progress {index}",
|
|
"singers": "Singer Progress",
|
|
"ext": "mp3",
|
|
}
|
|
},
|
|
)
|
|
)
|
|
relative_path = f"netease/Singer Progress/Song Local Progress {index} - {identifier}.mp3"
|
|
absolute_path = library_root / relative_path
|
|
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
|
absolute_path.write_bytes(b"fake-audio")
|
|
repo.record_local_file(
|
|
song_id=song_id,
|
|
backend_id=backend_id,
|
|
relative_path=relative_path.replace("\\", "/"),
|
|
file_size_bytes=10,
|
|
ext="mp3",
|
|
quality_label=None,
|
|
)
|
|
song_rows.append((song_id, absolute_path))
|
|
|
|
downloader = CatalogDownloader(repository=repo, worker_count=3)
|
|
active_workers = {"value": 0}
|
|
max_active_workers = {"value": 0}
|
|
state_lock = threading.Lock()
|
|
progress_updates: list[dict[str, object]] = []
|
|
|
|
def fake_sync(*, row, song_info, saved_path, overwrite_lyrics, worker_callback=None, display_text=None):
|
|
del song_info, saved_path, overwrite_lyrics, worker_callback, display_text
|
|
with state_lock:
|
|
active_workers["value"] += 1
|
|
max_active_workers["value"] = max(max_active_workers["value"], active_workers["value"])
|
|
time.sleep(0.05)
|
|
with state_lock:
|
|
active_workers["value"] -= 1
|
|
return "saved"
|
|
|
|
with patch.object(downloader, "_sync_lyrics_for_saved_song", side_effect=fake_sync):
|
|
summary = downloader.sync_local_lyrics(
|
|
limit=10,
|
|
overwrite_lyrics=False,
|
|
progress_callback=lambda **state: progress_updates.append(dict(state)),
|
|
)
|
|
|
|
self.assertEqual(3, summary["total"])
|
|
self.assertEqual(3, summary["processed"])
|
|
self.assertEqual(3, summary["saved"])
|
|
self.assertEqual(0, summary["skipped"])
|
|
self.assertEqual(0, summary["failed"])
|
|
self.assertGreaterEqual(max_active_workers["value"], 2)
|
|
self.assertGreaterEqual(len(progress_updates), 2)
|
|
self.assertEqual(0, progress_updates[0]["processed"])
|
|
self.assertEqual(0, progress_updates[0]["progress_percent"])
|
|
self.assertEqual(3, progress_updates[-1]["processed"])
|
|
self.assertEqual(100, progress_updates[-1]["progress_percent"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|