# Playlist Selective Download 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:** Turn the operations-console playlist page into a paginated playlist-pool manager that supports state filters, wanted markers, and selected-playlist download jobs. **Architecture:** Keep the existing `job_runs` execution pipeline unchanged, add a small `playlist_download_preferences` table for persistent operator intent, and compute playlist state live from `playlist_songs`, active local file locations, and running download job items for only the visible page. Use `CatalogRepository` for playlist-page aggregation, keep `OpsRepository` responsible for job creation, and connect both through FastAPI routes plus a thin browser-side selection layer. **Tech Stack:** Python 3, unittest, SQLite, FastAPI, Jinja2, vanilla JavaScript, existing `musicdl.catalogsync` repositories and job runner. --- ## File Structure ### New files - `tests/catalogsync/test_playlist_repository.py` - repository-level coverage for wanted markers, playlist-state aggregation, pagination, and status filters ### Modified files - `musicdl/catalogsync/db.py` - add the playlist preference table and query-supporting indexes - `musicdl/catalogsync/repository.py` - add playlist wanted-marker writes plus paginated playlist-page aggregation - `musicdl/catalogsync/ops/web.py` - wire playlist filters, playlist JSON APIs, and bulk download job creation - `musicdl/catalogsync/templates/ops/playlists.html` - replace the read-only table with a filterable, paginated, selectable playlist page - `musicdl/catalogsync/static/ops/app.js` - add current-page selection and bulk playlist action behavior - `tests/catalogsync/test_db.py` - verify the new table and indexes are created - `tests/catalogsync/test_ops_api.py` - verify the playlist page, playlist APIs, and playlist-scoped jobs - `docs/catalogsync.md` - document selective playlist download workflow and status semantics ## Task 1: Add Playlist Preference Schema And Indexes **Files:** - Modify: `musicdl/catalogsync/db.py` - Test: `tests/catalogsync/test_db.py` - [ ] **Step 1: Write the failing schema test** ```python def test_initialize_database_creates_playlist_download_preferences_table_and_indexes(self): from musicdl.catalogsync.db import initialize_database with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: db_path = Path(tmpdir) / "catalogsync.db" initialize_database(db_path).close() with closing(sqlite3.connect(db_path)) as conn: tables = { row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type = 'table'" ).fetchall() } indexes = { row[0] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type = 'index'" ).fetchall() } self.assertIn("playlist_download_preferences", tables) self.assertIn("idx_playlist_download_preferences_is_wanted", indexes) self.assertIn("idx_pool_playlists_playlist_id", indexes) self.assertIn("idx_job_items_running_song_id", indexes) ``` - [ ] **Step 2: Run the targeted test and verify it fails** Run: `python -m unittest tests.catalogsync.test_db.DatabaseSchemaTests.test_initialize_database_creates_playlist_download_preferences_table_and_indexes -v` Expected: - `FAIL` - the failure says `playlist_download_preferences` or one of the new indexes is missing - [ ] **Step 3: Implement the new table and indexes** ```python # musicdl/catalogsync/db.py REQUIRED_TABLES.add("playlist_download_preferences") SCHEMA_STATEMENTS.extend( [ """ CREATE TABLE IF NOT EXISTS playlist_download_preferences ( playlist_id INTEGER PRIMARY KEY, is_wanted INTEGER NOT NULL DEFAULT 1, marked_by TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) """, "CREATE INDEX IF NOT EXISTS idx_playlist_download_preferences_is_wanted ON playlist_download_preferences (is_wanted, updated_at DESC)", "CREATE INDEX IF NOT EXISTS idx_pool_playlists_playlist_id ON pool_playlists (playlist_id, pool_id)", "CREATE INDEX IF NOT EXISTS idx_playlist_songs_song_id ON playlist_songs (song_id, playlist_id)", "CREATE INDEX IF NOT EXISTS idx_file_assets_song_id ON file_assets (song_id)", "CREATE INDEX IF NOT EXISTS idx_job_items_running_song_id ON job_items (song_id, status)", ] ) ``` - [ ] **Step 4: Run the targeted test and verify it passes** Run: `python -m unittest tests.catalogsync.test_db.DatabaseSchemaTests.test_initialize_database_creates_playlist_download_preferences_table_and_indexes -v` Expected: - `OK` - sqlite schema inspection shows the new table and indexes - [ ] **Step 5: Commit** ```bash git add musicdl/catalogsync/db.py tests/catalogsync/test_db.py git commit -m "feat: add playlist download preference schema" ``` ## Task 2: Add Playlist Page Repository Queries **Files:** - Modify: `musicdl/catalogsync/repository.py` - Create: `tests/catalogsync/test_playlist_repository.py` - [ ] **Step 1: Write the failing repository tests** ```python import tempfile import unittest from pathlib import Path class PlaylistRepositoryTests(unittest.TestCase): def setUp(self): from musicdl.catalogsync.db import initialize_database from musicdl.catalogsync.models import CatalogSong, PlaylistCandidate from musicdl.catalogsync.ops.repository import OpsRepository from musicdl.catalogsync.ops.models import ItemStatus, StageStatus from musicdl.catalogsync.repository import CatalogRepository self.tmpdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) self.addCleanup(self.tmpdir.cleanup) self.db_path = Path(self.tmpdir.name) / "catalogsync.db" self.library_root = Path(self.tmpdir.name) / "library" initialize_database(self.db_path, default_library_root=self.library_root).close() self.repo = CatalogRepository(self.db_path) self.ops_repo = OpsRepository(self.db_path) self.CatalogSong = CatalogSong self.PlaylistCandidate = PlaylistCandidate self.ItemStatus = ItemStatus self.StageStatus = StageStatus self.local_backend_id = self.repo.get_default_backend_id() def _create_playlist(self, remote_id: str, name: str) -> int: return self.repo.upsert_playlist( self.PlaylistCandidate( platform="qq", remote_id=remote_id, name=name, url=f"https://example.invalid/playlist/{remote_id}", ) ) def _create_song(self, remote_song_id: str, name: str) -> int: return self.repo.upsert_song( self.CatalogSong( platform="qq", remote_song_id=remote_song_id, name=name, singers="Singer A", ext="mp3", file_size_bytes=128, quality_label="standard", ) ) def _mark_local_downloaded(self, song_id: int, relative_path: str) -> None: self.repo.record_local_file( song_id=song_id, backend_id=self.local_backend_id, relative_path=relative_path, file_size_bytes=128, ext="mp3", quality_label="standard", ) def _mark_running_download(self, song_id: int) -> None: job_id = self.ops_repo.create_job(job_type="download_only", config_snapshot={}) stage_id = self.ops_repo.create_stage( job_run_id=job_id, stage_type="download", seq_no=1, status=self.StageStatus.RUNNING, ) self.ops_repo.create_item( job_stage_id=stage_id, item_type="song_download", item_key=f"song:{song_id}", song_id=song_id, status=self.ItemStatus.RUNNING, payload={"row": {"id": song_id, "name": f"Song {song_id}"}}, ) def test_mark_and_unmark_playlists_wanted(self): playlist_id = self._create_playlist("wanted-1", "Wanted Playlist") self.repo.mark_playlists_wanted([playlist_id], marked_by="unit-test") page = self.repo.list_playlist_page(page=1, page_size=20) self.assertEqual(1, page["items"][0]["is_wanted"]) self.assertEqual("unit-test", page["items"][0]["marked_by"]) self.repo.unmark_playlists_wanted([playlist_id]) page = self.repo.list_playlist_page(page=1, page_size=20, wanted_only=True) self.assertEqual([], page["items"]) def test_list_playlist_page_computes_states_and_filters(self): unsynced_id = self._create_playlist("unsynced-1", "Unsynced Playlist") not_downloaded_id = self._create_playlist("not-downloaded-1", "Not Downloaded Playlist") downloading_id = self._create_playlist("downloading-1", "Downloading Playlist") partial_id = self._create_playlist("partial-1", "Partial Playlist") downloaded_id = self._create_playlist("downloaded-1", "Downloaded Playlist") not_downloaded_song = self._create_song("song-1", "Song 1") downloading_song = self._create_song("song-2", "Song 2") partial_song_a = self._create_song("song-3", "Song 3") partial_song_b = self._create_song("song-4", "Song 4") downloaded_song_a = self._create_song("song-5", "Song 5") downloaded_song_b = self._create_song("song-6", "Song 6") self.repo.link_playlist_song(not_downloaded_id, not_downloaded_song, 1) self.repo.link_playlist_song(downloading_id, downloading_song, 1) self.repo.link_playlist_song(partial_id, partial_song_a, 1) self.repo.link_playlist_song(partial_id, partial_song_b, 2) self.repo.link_playlist_song(downloaded_id, downloaded_song_a, 1) self.repo.link_playlist_song(downloaded_id, downloaded_song_b, 2) self._mark_running_download(downloading_song) self._mark_local_downloaded(partial_song_a, "qq/Singer A/song-3.mp3") self._mark_local_downloaded(downloaded_song_a, "qq/Singer A/song-5.mp3") self._mark_local_downloaded(downloaded_song_b, "qq/Singer A/song-6.mp3") page = self.repo.list_playlist_page(page=1, page_size=20) by_name = {row["name"]: row for row in page["items"]} self.assertEqual("unsynced", by_name["Unsynced Playlist"]["state_code"]) self.assertEqual(0, by_name["Unsynced Playlist"]["song_count"]) self.assertEqual("not_downloaded", by_name["Not Downloaded Playlist"]["state_code"]) self.assertEqual("downloading", by_name["Downloading Playlist"]["state_code"]) self.assertEqual("partial", by_name["Partial Playlist"]["state_code"]) self.assertEqual(1, by_name["Partial Playlist"]["downloaded_song_count"]) self.assertEqual("downloaded", by_name["Downloaded Playlist"]["state_code"]) self.assertEqual(2, by_name["Downloaded Playlist"]["downloaded_song_count"]) page = self.repo.list_playlist_page(status="partial", page=1, page_size=20) self.assertEqual(1, page["total_count"]) self.assertEqual("partial", page["items"][0]["state_code"]) wanted_page = self.repo.list_playlist_page( status="downloaded", page=1, page_size=20, wanted_only=True, ) self.assertEqual([], wanted_page["items"]) self.repo.mark_playlists_wanted([downloaded_id], marked_by="unit-test") wanted_page = self.repo.list_playlist_page( status="downloaded", page=1, page_size=20, wanted_only=True, ) self.assertEqual(1, wanted_page["total_count"]) self.assertEqual("Downloaded Playlist", wanted_page["items"][0]["name"]) ``` - [ ] **Step 2: Run the targeted tests and verify they fail** Run: `python -m unittest tests.catalogsync.test_playlist_repository -v` Expected: - `ERROR` or `FAIL` - `CatalogRepository` does not yet expose `mark_playlists_wanted`, `unmark_playlists_wanted`, or `list_playlist_page` - [ ] **Step 3: Implement wanted markers and paginated playlist aggregation** ```python # musicdl/catalogsync/repository.py PLAYLIST_STATE_LABELS = { "unsynced": "未同步", "not_downloaded": "未下载", "downloading": "下载中", "partial": "部分已下载", "downloaded": "已下载", } class CatalogRepository: def mark_playlists_wanted(self, playlist_ids: list[int], marked_by: str | None = None) -> None: if not playlist_ids: return with self._connection() as conn: conn.executemany( """ INSERT INTO playlist_download_preferences (playlist_id, is_wanted, marked_by) VALUES (?, 1, ?) ON CONFLICT(playlist_id) DO UPDATE SET is_wanted = 1, marked_by = excluded.marked_by, updated_at = CURRENT_TIMESTAMP """, [(playlist_id, marked_by) for playlist_id in playlist_ids], ) def unmark_playlists_wanted(self, playlist_ids: list[int]) -> None: if not playlist_ids: return placeholders = ", ".join("?" for _ in playlist_ids) self._execute( f"DELETE FROM playlist_download_preferences WHERE playlist_id IN ({placeholders})", tuple(playlist_ids), ) def list_playlist_page( self, *, page: int = 1, page_size: int = 50, platform: str | None = None, pool_kind: str | None = None, status: str | None = None, keyword: str | None = None, wanted_only: bool = False, ) -> dict[str, Any]: offset = (max(page, 1) - 1) * page_size filters: list[str] = [] params: list[Any] = [] if platform: filters.append("pb.platform = ?") params.append(platform) if pool_kind: filters.append("pb.pool_kind_names LIKE ?") params.append(f"%{pool_kind}%") if keyword: filters.append("(pb.name LIKE ? OR pb.remote_playlist_id LIKE ?)") params.extend([f"%{keyword}%", f"%{keyword}%"]) if wanted_only: filters.append("pb.is_wanted = 1") if status: filters.append("pb.state_code = ?") params.append(status) base_sql = """ WITH playlist_base AS ( SELECT p.id, p.platform, p.remote_playlist_id, p.name, p.updated_at, GROUP_CONCAT(DISTINCT pp.name) AS pool_names, GROUP_CONCAT(DISTINCT pp.pool_kind) AS pool_kind_names, COALESCE(pref.is_wanted, 0) AS is_wanted, pref.marked_by, COUNT(DISTINCT ps.song_id) AS song_count, COUNT(DISTINCT CASE WHEN local_files.song_id IS NOT NULL THEN ps.song_id END) AS downloaded_song_count, COUNT(DISTINCT CASE WHEN running_items.song_id IS NOT NULL THEN ps.song_id END) AS running_download_song_count, CASE WHEN COUNT(DISTINCT ps.song_id) = 0 THEN 'unsynced' WHEN COUNT(DISTINCT CASE WHEN running_items.song_id IS NOT NULL THEN ps.song_id END) > 0 THEN 'downloading' WHEN COUNT(DISTINCT CASE WHEN local_files.song_id IS NOT NULL THEN ps.song_id END) = 0 THEN 'not_downloaded' WHEN COUNT(DISTINCT CASE WHEN local_files.song_id IS NOT NULL THEN ps.song_id END) < COUNT(DISTINCT ps.song_id) THEN 'partial' ELSE 'downloaded' END AS state_code FROM playlists AS p LEFT JOIN pool_playlists AS rel ON rel.playlist_id = p.id LEFT JOIN playlist_pools AS pp ON pp.id = rel.pool_id LEFT JOIN playlist_download_preferences AS pref ON pref.playlist_id = p.id LEFT JOIN playlist_songs AS ps ON ps.playlist_id = p.id LEFT JOIN ( SELECT DISTINCT fa.song_id FROM file_locations AS fl JOIN file_assets AS fa ON fa.id = fl.file_asset_id JOIN storage_backends AS sb ON sb.id = fl.backend_id WHERE fl.status = 'active' AND sb.backend_type = 'local_fs' ) AS local_files ON local_files.song_id = ps.song_id LEFT JOIN ( SELECT DISTINCT ji.song_id FROM job_items AS ji JOIN job_stages AS js ON js.id = ji.job_stage_id WHERE ji.status = 'running' AND js.stage_type = 'download' AND ji.song_id IS NOT NULL ) AS running_items ON running_items.song_id = ps.song_id GROUP BY p.id ) """ where_sql = "WHERE " + " AND ".join(filters) if filters else "" rows = self._fetchall( base_sql + f""" SELECT * FROM playlist_base AS pb {where_sql} ORDER BY pb.updated_at DESC, pb.id DESC LIMIT ? OFFSET ? """, tuple(params + [page_size, offset]), ) total_row = self._fetchone( base_sql + f"SELECT COUNT(*) AS count_value FROM playlist_base AS pb {where_sql}", tuple(params), ) items = [] for row in rows: payload = dict(row) payload["state_label"] = PLAYLIST_STATE_LABELS[payload["state_code"]] items.append(payload) total_count = int(total_row["count_value"] if total_row else 0) return { "items": items, "page": max(page, 1), "page_size": page_size, "total_count": total_count, "total_pages": max((total_count + page_size - 1) // page_size, 1), } ``` - [ ] **Step 4: Run the targeted repository tests and verify they pass** Run: `python -m unittest tests.catalogsync.test_playlist_repository -v` Expected: - `OK` - repository results include `state_code`, `state_label`, `song_count`, `downloaded_song_count`, `running_download_song_count`, and `is_wanted` - [ ] **Step 5: Commit** ```bash git add musicdl/catalogsync/repository.py tests/catalogsync/test_playlist_repository.py git commit -m "feat: add playlist page repository queries" ``` ## Task 3: Add Playlist APIs And Playlist-Scoped Job Creation **Files:** - Modify: `musicdl/catalogsync/ops/web.py` - Modify: `tests/catalogsync/test_ops_api.py` - [ ] **Step 1: Add failing API tests for playlist filters and bulk actions** ```python def test_playlist_api_marks_wanted_and_creates_playlist_scoped_jobs(self): from musicdl.catalogsync.models import PlaylistCandidate from musicdl.catalogsync.repository import CatalogRepository client, db_path, _ = self._build_client() catalog_repo = CatalogRepository(db_path) playlist_id = catalog_repo.upsert_playlist( PlaylistCandidate( platform="qq", remote_id="web-1", name="Web Playlist", url="https://example.invalid/playlist/web-1", ) ) mark_response = client.post( "/api/playlists/mark-wanted", json={"playlist_ids": [playlist_id], "marked_by": "web-test"}, ) self.assertEqual(200, mark_response.status_code) self.assertEqual([playlist_id], mark_response.json()["playlist_ids"]) wanted_page = client.get("/api/playlists?wanted_only=1&page=1&page_size=20") self.assertEqual(200, wanted_page.status_code) self.assertEqual(1, wanted_page.json()["items"][0]["is_wanted"]) download_response = client.post( "/api/playlists/download", json={"playlist_ids": [playlist_id], "requested_by": "web-test"}, ) self.assertEqual(201, download_response.status_code) self.assertEqual("download_only", download_response.json()["job"]["job_type"]) self.assertEqual([playlist_id], download_response.json()["job"]["playlist_scope"]["playlist_ids"]) sync_download_response = client.post( "/api/playlists/sync-download", json={"playlist_ids": [playlist_id], "requested_by": "web-test"}, ) self.assertEqual(201, sync_download_response.status_code) self.assertEqual("sync_download", sync_download_response.json()["job"]["job_type"]) unmark_response = client.post( "/api/playlists/unmark-wanted", json={"playlist_ids": [playlist_id]}, ) self.assertEqual(200, unmark_response.status_code) ``` - [ ] **Step 2: Run the targeted API test and verify it fails** Run: `python -m unittest tests.catalogsync.test_ops_api.OperationsApiTests.test_playlist_api_marks_wanted_and_creates_playlist_scoped_jobs -v` Expected: - `404` for `/api/playlists/*` or `AttributeError` because the new endpoints do not exist yet - [ ] **Step 3: Implement playlist page route inputs and playlist APIs** ```python # musicdl/catalogsync/ops/web.py from musicdl.catalogsync.repository import CatalogRepository class PlaylistBulkRequest(BaseModel): playlist_ids: list[int] = Field(default_factory=list) requested_by: str | None = None marked_by: str | None = None def _normalize_playlist_ids(values: list[int]) -> list[int]: normalized: list[int] = [] seen: set[int] = set() for raw in values: playlist_id = int(raw) if playlist_id > 0 and playlist_id not in seen: normalized.append(playlist_id) seen.add(playlist_id) return normalized def _playlist_filter_options(catalog_repo: CatalogRepository) -> dict[str, list[str]]: return { "platforms": [ str(row["platform"]) for row in catalog_repo._fetchall( "SELECT DISTINCT platform FROM playlists ORDER BY platform ASC" ) ], "pool_kinds": [ str(row["pool_kind"]) for row in catalog_repo._fetchall( "SELECT DISTINCT pool_kind FROM playlist_pools ORDER BY pool_kind ASC" ) ], } def _build_playlist_job( repo: OpsRepository, env_manager: CatalogsyncEnvManager, job_type: Literal["download_only", "sync_download"], payload: PlaylistBulkRequest, ) -> dict[str, Any]: playlist_ids = _normalize_playlist_ids(payload.playlist_ids) if not playlist_ids: raise HTTPException(status_code=422, detail="playlist_ids is required") snapshot = env_manager.build_job_snapshot() job_id = repo.create_job( job_type=job_type, requested_by=payload.requested_by, config_snapshot=snapshot, sources=_normalize_allowed_list( snapshot.get("sources") or snapshot.get("SOURCES"), ALLOWED_COLLECT_SOURCES, ), download_sources=_normalize_allowed_list( snapshot.get("download_sources") or snapshot.get("DOWNLOAD_SOURCES"), ALLOWED_DOWNLOAD_SOURCES, ), playlist_scope={"playlist_ids": playlist_ids}, ) _ensure_job_stages(repo, job_id, job_type) job = repo.get_job(job_id) if job is None: raise HTTPException(status_code=500, detail="job create failed") return {"job": _serialize_job(job)} def create_app( db_path: str | Path, env_path: str | Path, *, start_runner: bool = False, runner_sleep_seconds: float = 1.0, ) -> FastAPI: db_file = Path(db_path) env_file = Path(env_path) initialize_database(db_file).close() repo = OpsRepository(db_file) catalog_repo = CatalogRepository(db_file) env_manager = CatalogsyncEnvManager( db_path=db_file, env_file_path=env_file, repository=repo, ) app = FastAPI(title="Catalogsync Operations Console") templates_dir = Path(__file__).resolve().parents[1] / "templates" static_dir = Path(__file__).resolve().parents[1] / "static" templates = Jinja2Templates(directory=str(templates_dir)) app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") @app.get("/playlists", response_class=HTMLResponse) def playlists_page( request: Request, page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=20, le=100), platform: str | None = None, pool_kind: str | None = None, status: str | None = None, keyword: str | None = None, wanted_only: bool = False, ): if page_size not in {20, 50, 100}: raise HTTPException(status_code=422, detail="page_size must be one of 20, 50, 100") playlist_page = catalog_repo.list_playlist_page( page=page, page_size=page_size, platform=platform, pool_kind=pool_kind, status=status, keyword=keyword, wanted_only=wanted_only, ) return templates.TemplateResponse( name="ops/playlists.html", request=request, context={ "title": "Playlists", "playlist_page": playlist_page, "playlist_sources": _playlist_source_stats(repo), "filter_options": _playlist_filter_options(catalog_repo), "filters": { "page": page, "page_size": page_size, "platform": platform or "", "pool_kind": pool_kind or "", "status": status or "", "keyword": keyword or "", "wanted_only": wanted_only, }, }, ) @app.get("/api/playlists") def api_playlists( page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=20, le=100), platform: str | None = None, pool_kind: str | None = None, status: str | None = None, keyword: str | None = None, wanted_only: bool = False, ): if page_size not in {20, 50, 100}: raise HTTPException(status_code=422, detail="page_size must be one of 20, 50, 100") return catalog_repo.list_playlist_page( page=page, page_size=page_size, platform=platform, pool_kind=pool_kind, status=status, keyword=keyword, wanted_only=wanted_only, ) @app.post("/api/playlists/mark-wanted") def api_mark_playlists_wanted(payload: PlaylistBulkRequest): playlist_ids = _normalize_playlist_ids(payload.playlist_ids) if not playlist_ids: raise HTTPException(status_code=422, detail="playlist_ids is required") catalog_repo.mark_playlists_wanted(playlist_ids, marked_by=payload.marked_by or payload.requested_by) return {"playlist_ids": playlist_ids, "marked_count": len(playlist_ids)} @app.post("/api/playlists/unmark-wanted") def api_unmark_playlists_wanted(payload: PlaylistBulkRequest): playlist_ids = _normalize_playlist_ids(payload.playlist_ids) if not playlist_ids: raise HTTPException(status_code=422, detail="playlist_ids is required") catalog_repo.unmark_playlists_wanted(playlist_ids) return {"playlist_ids": playlist_ids, "unmarked_count": len(playlist_ids)} @app.post("/api/playlists/download", status_code=201) def api_download_selected_playlists(payload: PlaylistBulkRequest): return _build_playlist_job(repo, env_manager, "download_only", payload) @app.post("/api/playlists/sync-download", status_code=201) def api_sync_download_selected_playlists(payload: PlaylistBulkRequest): return _build_playlist_job(repo, env_manager, "sync_download", payload) return app ``` - [ ] **Step 4: Run the targeted API test and verify it passes** Run: `python -m unittest tests.catalogsync.test_ops_api.OperationsApiTests.test_playlist_api_marks_wanted_and_creates_playlist_scoped_jobs -v` Expected: - `OK` - API responses include playlist rows with `is_wanted` - created jobs contain `playlist_scope.playlist_ids` - [ ] **Step 5: Commit** ```bash git add musicdl/catalogsync/ops/web.py tests/catalogsync/test_ops_api.py git commit -m "feat: add playlist selection APIs" ``` ## Task 4: Build The Playlist Management Page UX **Files:** - Modify: `musicdl/catalogsync/templates/ops/playlists.html` - Modify: `musicdl/catalogsync/static/ops/app.js` - Modify: `tests/catalogsync/test_ops_api.py` - [ ] **Step 1: Add failing page-render tests for filters, selection, and pagination** ```python def test_playlists_page_renders_filters_selection_and_bulk_actions(self): client, _, _ = self._build_client() response = client.get("/playlists?page=1&page_size=20&status=not_downloaded") self.assertEqual(200, response.status_code) self.assertIn('name="status"', response.text) self.assertIn('name="page_size"', response.text) self.assertIn('name="wanted_only"', response.text) self.assertIn('data-playlist-select-all', response.text) self.assertIn('data-playlist-clear-selection', response.text) self.assertIn('data-playlist-action="download"', response.text) self.assertIn('data-playlist-action="sync-download"', response.text) self.assertIn('data-playlist-action="mark-wanted"', response.text) self.assertIn('data-playlist-action="unmark-wanted"', response.text) self.assertIn('data-playlist-pagination', response.text) ``` - [ ] **Step 2: Run the targeted page-render test and verify it fails** Run: `python -m unittest tests.catalogsync.test_ops_api.OperationsApiTests.test_playlists_page_renders_filters_selection_and_bulk_actions -v` Expected: - `FAIL` - the current page does not render selection checkboxes, bulk buttons, or pagination markup - [ ] **Step 3: Replace the read-only playlist template and add browser actions** ```html
| ID | 平台 | Remote ID | 名称 | 来源 | 歌曲数 | 已下载 | 状态 | 待下载 | 更新时间 | |
|---|---|---|---|---|---|---|---|---|---|---|
| {{ playlist.id }} | {{ playlist.platform }} | {{ playlist.remote_playlist_id }} | {{ playlist.name }} | {{ playlist.pool_names or "-" }} | {{ playlist.song_count }} | {{ playlist.downloaded_song_count }} | {{ playlist.state_label }} | {% if playlist.is_wanted %}是{% else %}否{% endif %} | {{ playlist.updated_at }} |