Files
musicdl-catalog-sync-suite/catalog-sync/docs/superpowers/plans/2026-04-17-playlist-selective-download.md
T

39 KiB

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

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

# 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

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

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

# 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

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

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

# 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

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

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

<!-- musicdl/catalogsync/templates/ops/playlists.html -->
<div class="card" data-playlists-page>
  <form method="get" class="filters">
    <input type="text" name="keyword" value="{{ filters.keyword }}" placeholder="Playlist name or remote ID" />
    <select name="platform">
      <option value="">全部平台</option>
      {% for value in filter_options.platforms %}
      <option value="{{ value }}" {% if filters.platform == value %}selected{% endif %}>{{ value }}</option>
      {% endfor %}
    </select>
    <select name="pool_kind">
      <option value="">全部来源</option>
      {% for value in filter_options.pool_kinds %}
      <option value="{{ value }}" {% if filters.pool_kind == value %}selected{% endif %}>{{ value }}</option>
      {% endfor %}
    </select>
    <select name="status">
      <option value="">全部</option>
      <option value="unsynced" {% if filters.status == "unsynced" %}selected{% endif %}>未同步</option>
      <option value="not_downloaded" {% if filters.status == "not_downloaded" %}selected{% endif %}>未下载</option>
      <option value="downloading" {% if filters.status == "downloading" %}selected{% endif %}>下载中</option>
      <option value="partial" {% if filters.status == "partial" %}selected{% endif %}>部分已下载</option>
      <option value="downloaded" {% if filters.status == "downloaded" %}selected{% endif %}>已下载</option>
    </select>
    <label><input type="checkbox" name="wanted_only" value="1" {% if filters.wanted_only %}checked{% endif %} /> 只看待下载</label>
    <select name="page_size">
      {% for value in (20, 50, 100) %}
      <option value="{{ value }}" {% if filters.page_size == value %}selected{% endif %}>{{ value }}/页</option>
      {% endfor %}
    </select>
    <button type="submit">筛选</button>
  </form>

  <div class="toolbar">
    <button type="button" data-playlist-select-all>全选本页</button>
    <button type="button" data-playlist-clear-selection>清空选择</button>
    <span data-playlist-selection-count>已选择 0 个歌单</span>
    <button type="button" data-playlist-action="download">下载已同步所选歌单</button>
    <button type="button" data-playlist-action="sync-download">同步后下载所选歌单</button>
    <button type="button" data-playlist-action="mark-wanted">加入待下载清单</button>
    <button type="button" data-playlist-action="unmark-wanted">移出待下载清单</button>
  </div>

  <table data-playlist-table>
    <thead>
      <tr>
        <th></th>
        <th>ID</th>
        <th>平台</th>
        <th>Remote ID</th>
        <th>名称</th>
        <th>来源</th>
        <th>歌曲数</th>
        <th>已下载</th>
        <th>状态</th>
        <th>待下载</th>
        <th>更新时间</th>
      </tr>
    </thead>
    <tbody>
      {% for playlist in playlist_page.items %}
      <tr>
        <td><input type="checkbox" value="{{ playlist.id }}" data-playlist-checkbox /></td>
        <td>{{ playlist.id }}</td>
        <td>{{ playlist.platform }}</td>
        <td>{{ playlist.remote_playlist_id }}</td>
        <td>{{ playlist.name }}</td>
        <td>{{ playlist.pool_names or "-" }}</td>
        <td>{{ playlist.song_count }}</td>
        <td>{{ playlist.downloaded_song_count }}</td>
        <td>{{ playlist.state_label }}</td>
        <td>{% if playlist.is_wanted %}是{% else %}否{% endif %}</td>
        <td>{{ playlist.updated_at }}</td>
      </tr>
      {% endfor %}
    </tbody>
  </table>

  <nav data-playlist-pagination>
    <a href="?page={{ 1 if playlist_page.page <= 1 else playlist_page.page - 1 }}&page_size={{ filters.page_size }}&platform={{ filters.platform }}&pool_kind={{ filters.pool_kind }}&status={{ filters.status }}&keyword={{ filters.keyword }}{% if filters.wanted_only %}&wanted_only=1{% endif %}">上一页</a>
    <span>第 {{ playlist_page.page }} / {{ playlist_page.total_pages }} 页,共 {{ playlist_page.total_count }} 个歌单</span>
    <a href="?page={{ playlist_page.total_pages if playlist_page.page >= playlist_page.total_pages else playlist_page.page + 1 }}&page_size={{ filters.page_size }}&platform={{ filters.platform }}&pool_kind={{ filters.pool_kind }}&status={{ filters.status }}&keyword={{ filters.keyword }}{% if filters.wanted_only %}&wanted_only=1{% endif %}">下一页</a>
  </nav>
</div>
// musicdl/catalogsync/static/ops/app.js
function postJson(path, payload) {
  return window.fetch(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  }).then(function (response) {
    return response.json().then(function (data) {
      if (!response.ok) {
        throw new Error(data.detail || "request failed");
      }
      return data;
    });
  });
}

function bindPlaylistPage() {
  var root = document.querySelector("[data-playlists-page]");
  if (!root) {
    return;
  }

  var checkboxes = Array.prototype.slice.call(
    root.querySelectorAll("[data-playlist-checkbox]")
  );
  var countNode = root.querySelector("[data-playlist-selection-count]");

  function selectedPlaylistIds() {
    return checkboxes
      .filter(function (checkbox) {
        return checkbox.checked;
      })
      .map(function (checkbox) {
        return Number(checkbox.value);
      });
  }

  function updateSelectionCount() {
    if (!countNode) {
      return;
    }
    countNode.textContent = "已选择 " + selectedPlaylistIds().length + " 个歌单";
  }

  var selectAllButton = root.querySelector("[data-playlist-select-all]");
  if (selectAllButton) {
    selectAllButton.addEventListener("click", function () {
      checkboxes.forEach(function (checkbox) {
        checkbox.checked = true;
      });
      updateSelectionCount();
    });
  }

  var clearButton = root.querySelector("[data-playlist-clear-selection]");
  if (clearButton) {
    clearButton.addEventListener("click", function () {
      checkboxes.forEach(function (checkbox) {
        checkbox.checked = false;
      });
      updateSelectionCount();
    });
  }

  checkboxes.forEach(function (checkbox) {
    checkbox.addEventListener("change", updateSelectionCount);
  });
  updateSelectionCount();

  root.querySelectorAll("[data-playlist-action]").forEach(function (button) {
    button.addEventListener("click", function () {
      var playlistIds = selectedPlaylistIds();
      if (!playlistIds.length) {
        showMessage("请先选择至少一个歌单。", true);
        return;
      }
      var action = button.getAttribute("data-playlist-action");
      var endpointMap = {
        download: "/api/playlists/download",
        "sync-download": "/api/playlists/sync-download",
        "mark-wanted": "/api/playlists/mark-wanted",
        "unmark-wanted": "/api/playlists/unmark-wanted",
      };
      postJson(endpointMap[action], { playlist_ids: playlistIds })
        .then(function (data) {
          if (data.job && data.job.id) {
            window.location.href = "/jobs/" + data.job.id;
            return;
          }
          window.location.reload();
        })
        .catch(function (error) {
          showMessage(error.message || "request failed", true);
        });
    });
  });
}

bindPlaylistPage();
  • Step 4: Run the targeted page-render test and verify it passes

Run: python -m unittest tests.catalogsync.test_ops_api.OperationsApiTests.test_playlists_page_renders_filters_selection_and_bulk_actions -v

Expected:

  • OK

  • /playlists HTML includes filter controls, bulk-action buttons, selection markup, and pagination text

  • Step 5: Commit

git add musicdl/catalogsync/templates/ops/playlists.html musicdl/catalogsync/static/ops/app.js tests/catalogsync/test_ops_api.py
git commit -m "feat: add playlist management page"

Task 5: Document The Flow And Run Regression Coverage

Files:

  • Modify: docs/catalogsync.md

  • Step 1: Update the operator documentation

## 歌单池选择性下载

- 访问 `/playlists` 可查看歌单池分页列表,支持 `平台 / 来源 / 状态 / 关键字 / 只看待下载` 筛选
- 歌单状态含义:
  - `未同步`:歌单还没有 `playlist_songs`
  - `未下载`:歌单已同步但本地还没有任何对应歌曲文件
  - `下载中`:存在 `running` 的下载 job item 命中该歌单歌曲
  - `部分已下载`:歌单歌曲只有一部分在本地有 active file
  - `已下载`:歌单全部歌曲都已命中本地 active file
- 当前页支持临时勾选、整页全选、加入待下载清单、下载已同步歌单、同步后下载歌单
- 通过页面创建的下载任务仍然复用 `download_only` / `sync_download`,区别只是在 `playlist_scope.playlist_ids`
  • Step 2: Run focused regression tests

Run: python -m unittest tests.catalogsync.test_db tests.catalogsync.test_playlist_repository tests.catalogsync.test_ops_api -v

Expected:

  • OK

  • schema, repository, and API coverage all pass together

  • Step 3: Run the full catalogsync test suite

Run: python -m unittest discover -s tests/catalogsync -v

Expected:

  • OK

  • no pre-existing catalogsync behavior regresses after the playlist-page changes

  • Step 4: Commit

git add docs/catalogsync.md
git commit -m "docs: describe selective playlist download flow"