# Playlist File Run Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a `--playlist-file` option to `musicdl.catalogsync` so `run` can process only playlists listed in a text file while preserving the current default workflow when the option is absent. **Architecture:** Keep the existing `collect -> sync -> download` path unchanged and add a narrow file-driven branch in `run`. Parse playlist-file lines into normalized playlist import entries, upsert them into the existing catalog tables under a `manual_file` pool, then sync and download only the referenced playlist IDs. **Tech Stack:** Python stdlib (`pathlib`, `dataclasses`, `urllib.parse`), `click`, existing `musicdl.catalogsync` modules, `unittest` --- ### Task 1: Add file playlist parsing and manual-file pool support **Files:** - Create: `musicdl/catalogsync/manual_playlists.py` - Modify: `musicdl/catalogsync/models.py` - Modify: `musicdl/catalogsync/repository.py` - Test: `tests/catalogsync/test_manual_playlists.py` - [ ] **Step 1: Write failing tests for playlist-file parsing** ```python import tempfile import unittest from pathlib import Path class ManualPlaylistParsingTests(unittest.TestCase): def test_parse_playlist_file_supports_url_and_platform_url_lines(self): from musicdl.catalogsync.manual_playlists import parse_playlist_file with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: playlist_file = Path(tmpdir) / "playlists.txt" playlist_file.write_text( "\n".join( [ "# comment", "https://music.163.com/#/playlist?id=17745989905", "qq,https://y.qq.com/n/ryqq/playlist/7707261125", "https://music.163.com/#/playlist?id=17745989905", "", ] ), encoding="utf-8", ) parsed = parse_playlist_file(playlist_file) self.assertEqual(5, parsed.total_lines) self.assertEqual(0, parsed.skipped_lines) self.assertEqual(2, len(parsed.entries)) self.assertEqual("netease", parsed.entries[0].platform) self.assertEqual("17745989905", parsed.entries[0].remote_id) self.assertEqual("qq", parsed.entries[1].platform) self.assertEqual("7707261125", parsed.entries[1].remote_id) ``` - [ ] **Step 2: Run the focused parser test to verify it fails** Run: `python -m unittest tests.catalogsync.test_manual_playlists.ManualPlaylistParsingTests.test_parse_playlist_file_supports_url_and_platform_url_lines -v` Expected: FAIL with import error for `musicdl.catalogsync.manual_playlists` or missing parser helpers - [ ] **Step 3: Implement the parser module and manual-file pool helper** ```python from __future__ import annotations from dataclasses import dataclass from pathlib import Path from .models import PlaylistCandidate @dataclass(slots=True) class ParsedPlaylistFile: entries: list[PlaylistCandidate] total_lines: int skipped_lines: int def parse_playlist_file(path: str | Path) -> ParsedPlaylistFile: raise NotImplementedError ``` ```python def get_or_create_manual_file_pool(self, playlist_file: str | Path) -> int: resolved = Path(playlist_file).resolve() return self.upsert_playlist_pool( platform="manual", pool_kind="manual_file", external_id=f"manual_file:{resolved}", name=f"Manual File Import: {resolved.name}", url=str(resolved), ) ``` - [ ] **Step 4: Re-run the focused parser tests** Run: `python -m unittest tests.catalogsync.test_manual_playlists -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add musicdl/catalogsync/manual_playlists.py musicdl/catalogsync/models.py musicdl/catalogsync/repository.py tests/catalogsync/test_manual_playlists.py git commit -m "feat: add playlist file parsing support" ``` ### Task 2: Add manual playlist import and targeted sync **Files:** - Modify: `musicdl/catalogsync/services.py` - Modify: `musicdl/catalogsync/repository.py` - Test: `tests/catalogsync/test_manual_playlists.py` - Test: `tests/catalogsync/test_services.py` - [ ] **Step 1: Write failing tests for manual playlist import and targeted sync** ```python def test_import_manual_playlists_creates_manual_pool_and_returns_playlist_ids(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( "https://music.163.com/#/playlist?id=17745989905\n", 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) self.assertEqual(1, len(playlist_ids)) self.assertEqual(1, repo.count_rows("playlists")) self.assertEqual( 1, len(repo.list_pool_playlist_links(repo.get_or_create_manual_file_pool(playlist_file))), ) ``` ```python def test_sync_specific_playlists_only_processes_requested_playlist_ids(self): from unittest.mock import patch 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"])) ``` - [ ] **Step 2: Run the focused services tests to verify they fail** Run: `python -m unittest tests.catalogsync.test_manual_playlists tests.catalogsync.test_services -v` Expected: FAIL with missing `import_manual_playlists`, `list_playlists_by_ids`, and `sync_specific_playlists` - [ ] **Step 3: Implement manual playlist import and targeted sync entry points** ```python def list_playlists_by_ids(self, playlist_ids: list[int]) -> list[sqlite3.Row]: placeholders = ", ".join("?" for _ in playlist_ids) return self._fetchall( f"SELECT * FROM playlists WHERE id IN ({placeholders}) ORDER BY id ASC", tuple(playlist_ids), ) ``` ```python def import_manual_playlists(self, playlist_file: str | Path, candidates: list[PlaylistCandidate]) -> list[int]: pool_id = self.repository.get_or_create_manual_file_pool(playlist_file) playlist_ids = [] for candidate in candidates: playlist_id = self.repository.upsert_playlist(candidate) self.repository.link_pool_playlist(pool_id, playlist_id) playlist_ids.append(playlist_id) return playlist_ids def sync_specific_playlists(self, playlist_ids: list[int]) -> int: processed = 0 for playlist_row in self.repository.list_playlists_by_ids(playlist_ids): song_infos = self.resolve_playlist_song_infos(playlist_row) for pool_id in self.repository.get_pool_ids_for_playlist(int(playlist_row["id"])): self.store_playlist_songs(int(playlist_row["id"]), pool_id, song_infos) processed += len(song_infos) return processed ``` - [ ] **Step 4: Re-run the focused services tests** Run: `python -m unittest tests.catalogsync.test_manual_playlists tests.catalogsync.test_services -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add musicdl/catalogsync/services.py musicdl/catalogsync/repository.py tests/catalogsync/test_manual_playlists.py tests/catalogsync/test_services.py git commit -m "feat: add manual playlist import and targeted sync" ``` ### Task 3: Add targeted download planning for selected playlists **Files:** - Modify: `musicdl/catalogsync/downloader.py` - Modify: `musicdl/catalogsync/repository.py` - Test: `tests/catalogsync/test_services.py` - [ ] **Step 1: Write a failing test for limiting downloads to selected playlist IDs** ```python def test_download_planner_can_limit_queue_to_specific_playlists(self): from musicdl.catalogsync.db import initialize_database from musicdl.catalogsync.models import CatalogSong, PlaylistCandidate 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" 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]) ``` - [ ] **Step 2: Run the focused download planner test to verify it fails** Run: `python -m unittest tests.catalogsync.test_services.CatalogServiceTests.test_download_planner_can_limit_queue_to_specific_playlists -v` Expected: FAIL because `playlist_ids` is unsupported - [ ] **Step 3: Implement repository and downloader filtering by playlist IDs** ```python def list_pending_download_songs( self, sources: list[str] | None = None, limit: int | None = None, playlist_ids: list[int] | None = None, ) -> list[sqlite3.Row]: query = """ SELECT DISTINCT s.* FROM songs s JOIN playlist_songs ps ON ps.song_id = s.id WHERE NOT EXISTS ( SELECT 1 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 = s.id AND fl.status = 'active' AND sb.backend_type = 'local_fs' ) """ ``` ```python def build_download_queue( self, sources: list[str] | None = None, limit: int | None = None, playlist_ids: list[int] | None = None, ) -> list[dict]: rows = self.repository.list_pending_download_songs( sources=sources, limit=limit, playlist_ids=playlist_ids, ) queue = [] for row in rows: if self.repository.song_has_active_local_file(int(row["id"])): continue item = dict(row) item["song_id"] = int(row["id"]) queue.append(item) return queue ``` - [ ] **Step 4: Re-run the focused download tests** Run: `python -m unittest tests.catalogsync.test_services -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add musicdl/catalogsync/downloader.py musicdl/catalogsync/repository.py tests/catalogsync/test_services.py git commit -m "feat: limit downloads to selected playlists" ``` ### Task 4: Wire `--playlist-file` into the CLI **Files:** - Modify: `musicdl/catalogsync/cli.py` - Modify: `tests/catalogsync/test_cli.py` - Test: `tests/catalogsync/test_manual_playlists.py` - [ ] **Step 1: Write failing CLI tests for the file-driven `run` path** ```python def test_run_command_uses_playlist_file_branch_without_collect(self): from musicdl.catalogsync.cli import cli runner = CliRunner() 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( "https://music.163.com/#/playlist?id=17745989905\n", encoding="utf-8", ) with patch("musicdl.catalogsync.cli.CatalogSyncApplication") as app_cls: app = app_cls.return_value result = runner.invoke( cli, [ "run", "--db", str(db_path), "--library-root", str(Path(tmpdir) / "library"), "--playlist-file", str(playlist_file), ], ) self.assertEqual(0, result.exit_code, msg=result.output) app.collect_playlists.assert_not_called() app.run_playlist_file.assert_called_once() ``` - [ ] **Step 2: Run the focused CLI test to verify it fails** Run: `python -m unittest tests.catalogsync.test_cli.CatalogCliTests.test_run_command_uses_playlist_file_branch_without_collect -v` Expected: FAIL because `--playlist-file` and `run_playlist_file` do not exist - [ ] **Step 3: Implement the CLI branch and application method** ```python def run_playlist_file(self, playlist_file: str, sources: list[str] | None = None, limit: int | None = None): parsed = parse_playlist_file(playlist_file) playlist_ids = self.service.import_manual_playlists(playlist_file, parsed.entries) if limit is not None: playlist_ids = playlist_ids[:limit] synced = self.service.sync_specific_playlists(playlist_ids) downloaded = self.downloader.download_pending( self.library_root, sources=sources, limit=limit, playlist_ids=playlist_ids, ) return parsed, synced, downloaded ``` - [ ] **Step 4: Re-run the focused CLI tests** Run: `python -m unittest tests.catalogsync.test_cli -v` Expected: PASS - [ ] **Step 5: Commit** ```bash git add musicdl/catalogsync/cli.py tests/catalogsync/test_cli.py tests/catalogsync/test_manual_playlists.py git commit -m "feat: add playlist file run option" ``` ### Task 5: Update docs and run final verification **Files:** - Modify: `docs/catalogsync.md` - Modify: `README.md` - Test: `tests/catalogsync/test_manual_playlists.py` - Test: `tests/catalogsync/test_cli.py` - Test: `tests/catalogsync/test_services.py` - [ ] **Step 1: Update docs with `--playlist-file` examples and file format rules** ```markdown ### 从文件读取歌单 ```bash musicdl-catalogsync run --db D:\catalogsync\catalogsync.db --library-root E:\MusicLibrary --playlist-file D:\lists\playlists.txt ``` 文件支持: - 一行一个歌单 URL - `平台,歌单URL` - `#` 注释行 ``` - [ ] **Step 2: Run the full catalogsync test suite** Run: `python -m unittest discover -s tests/catalogsync -v` Expected: PASS - [ ] **Step 3: Run a manual smoke flow using a temporary playlist file** Run: ```bash python -m musicdl.catalogsync.cli init-db --db .tmp\playlist-file\catalogsync.db --library-root .tmp\playlist-file\library python -m musicdl.catalogsync.cli run --db .tmp\playlist-file\catalogsync.db --library-root .tmp\playlist-file\library --playlist-file .tmp\playlist-file\playlists.txt ``` Expected: - command exits 0 - the file-driven branch skips `collect` - playlists from the file are imported and processed - [ ] **Step 4: Commit** ```bash git add docs/catalogsync.md README.md git commit -m "docs: add playlist file run usage" ```