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_preferencesor 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:
-
ERRORorFAIL -
CatalogRepositorydoes not yet exposemark_playlists_wanted,unmark_playlists_wanted, orlist_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, andis_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:
-
404for/api/playlists/*orAttributeErrorbecause 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 -
/playlistsHTML 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
catalogsyncbehavior regresses after the playlist-page changes -
Step 4: Commit
git add docs/catalogsync.md
git commit -m "docs: describe selective playlist download flow"