# Playlist Selective Download Design ## Goal Extend the `catalogsync` operations console so operators can download songs by selected playlists instead of relying on uncontrolled full-library download runs. The new flow must allow the operator to: - browse playlists through a paginated playlist-pool page - filter playlists by download state - select playlists on the current page - run either `download already-synced songs` or `sync then download` for the selected playlists - persist a separate `wanted for download` marker for playlists that should remain in a long-term queue This design keeps the existing job system and downloader intact wherever possible. ## Scope ### In Scope - Upgrade `/playlists` from a read-only list into a playlist-pool management page - Add server-side pagination to the playlist page - Add playlist filtering by: - platform - pool kind - keyword - download state - wanted marker - Add current-page checkbox selection and current-page select-all - Add bulk actions: - `下载已同步所选歌单` - `同步后下载所选歌单` - `加入待下载清单` - `移出待下载清单` - Add a persistent playlist-level preference table for the wanted marker - Reuse existing `download_only` and `sync_download` jobs by passing `playlist_scope.playlist_ids` - Compute playlist download state from live catalog and runner data ### Out of Scope - Cross-page remembered temporary selection - Saved named selection sets - Manual editing of computed playlist download state - New downloader semantics outside the existing job system - Per-playlist download history pages - Automatic cancellation or reprioritization of in-flight jobs in this design ## User Decisions Captured This design encodes the following confirmed product decisions: - Playlist download state uses multiple states instead of a simple downloaded flag - The operator needs both: - temporary current-page selection for immediate actions - persistent playlist-level wanted markers - If a song appears in multiple playlists, it counts as downloaded for all of them once the same `song_id` has an active local file - Playlists with no synced `playlist_songs` must show a dedicated `未同步` state - The playlist page must support pagination, page-level select-all, and download-state filtering - `下载中` is shown only when there is active running download work for songs belonging to that playlist - The state filter set is: - `全部` - `未同步` - `未下载` - `下载中` - `部分已下载` - `已下载` ## Constraints - Existing `catalogsync` job queue remains the only execution path - Only one active job still runs at a time - Existing `download_only` and `sync_download` job types should remain valid and reusable - SQLite remains the backing store - The first version should optimize for operational clarity and low migration risk over advanced UX - The implementation should avoid full-library recomputation for every playlist page load because the NAS dataset is already large ## Existing System Reuse The current codebase already provides two critical capabilities that should be reused instead of reinvented: 1. `playlist_scope.playlist_ids` already exists on jobs 2. download planning already supports filtering by `playlist_ids` Relevant current behavior: - `download_only` is already a first-class job type - `sync_download` is already a first-class job type - `OpsRunner` already resolves `playlist_scope.playlist_ids` - `DownloadPlanner.build_download_queue()` already accepts `playlist_ids` - `CatalogRepository.list_pending_download_songs()` already supports `playlist_ids` Because of this, the main work is playlist management UI, playlist-state aggregation, and lightweight playlist preference persistence. ## Recommended Approach Use a mixed model: - compute playlist state live for the current result page - persist only playlist-level operator intent (`wanted for download`) - use current bulk selections only as transient request payload ### Why This Approach This approach avoids two bad extremes: - **Pure runtime-only UI** would lose long-term operator intent such as a curated wanted list - **Full cached playlist-state tables** would add a large invalidation burden after every sync, download, retry, or file-state change The mixed approach gives: - correct and current state for the visible page - minimal schema change - low-risk reuse of the current pipeline ## Data Model ### New Table: `playlist_download_preferences` Purpose: - persist operator intent for playlists that should stay in a long-term wanted queue Recommended fields: ```text 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 ``` Notes: - use one row per playlist, not an event log - `playlist_id` should be unique and serve as the primary key - deleting or setting `is_wanted = 0` are both acceptable implementation choices; prefer explicit row persistence only if it simplifies auditing ### No Cached Playlist State Table Do not add a second table that stores computed playlist state such as `已下载 / 未下载 / 部分已下载`. Reason: - those values depend on current `playlist_songs` - current local file availability can change - current running download items can change The state should therefore be computed from live data for the current page. ## Playlist State Model For each playlist row, calculate: - `song_count` - `downloaded_song_count` - `running_download_song_count` - `is_wanted` ### State Rules - `未同步` - `song_count = 0` - `下载中` - `song_count > 0` - `running_download_song_count > 0` - `未下载` - `song_count > 0` - `downloaded_song_count = 0` - `running_download_song_count = 0` - `部分已下载` - `song_count > 0` - `0 < downloaded_song_count < song_count` - `running_download_song_count = 0` - `已下载` - `song_count > 0` - `downloaded_song_count = song_count` - `running_download_song_count = 0` ### Downloaded Song Counting Rule For one playlist song entry, the song is treated as downloaded if: - the same `song_id` has an active local file location It does not matter which playlist originally triggered that file download. ### Running Download Counting Rule For one playlist song entry, the song is treated as currently downloading if: - there is a `running` job item in stage `download` - that item points to the same `song_id` Queued-but-not-running work does not count as `下载中`. ## Playlist Page Design ### URL Keep the main page at: ```text /playlists ``` ### Query Parameters Support these server-side filters: - `page` - `page_size` - `platform` - `pool_kind` - `status` - `keyword` - `wanted_only` ### Default Pagination Recommended defaults: - default `page_size = 50` - allow `20 / 50 / 100` Reason: - current NAS data already contains more than ten thousand playlists - a fixed `LIMIT 500` list will become increasingly unusable ### Table Columns Recommended visible columns: - checkbox - playlist id - platform - remote playlist id - playlist name - pool names - song count - downloaded song count - computed state - wanted marker - updated at ### Toolbar Actions Recommended top-toolbar controls: - platform filter - pool-kind filter - state filter - keyword search - wanted-only filter - page-size selector ### Bulk Action Groups #### Temporary Selection Actions Apply only to the currently selected playlist ids: - `下载已同步所选歌单` - `同步后下载所选歌单` #### Persistent Marker Actions Apply only to the currently selected playlist ids: - `加入待下载清单` - `移出待下载清单` ### Selection Behavior The first version should support only current-page temporary selection. Rules: - `全选本页` selects all rows visible on the current page - changing page clears temporary selection - filters changing the result page clear temporary selection - persistent wanted markers remain stored independently of temporary selection This keeps implementation simple and predictable. ## API Design ### `GET /api/playlists` Purpose: - return one filtered page of playlist rows with computed state Request parameters: - `page` - `page_size` - `platform` - `pool_kind` - `status` - `keyword` - `wanted_only` Response shape: ```json { "items": [ { "id": 123, "platform": "qq", "remote_playlist_id": "456", "name": "Example Playlist", "pool_names": "QQ 音乐歌单广场", "song_count": 120, "downloaded_song_count": 80, "state": "部分已下载", "is_wanted": true, "updated_at": "2026-04-17 00:00:00" } ], "page": 1, "page_size": 50, "total": 12345 } ``` ### `POST /api/playlists/mark-wanted` Purpose: - persist wanted markers for the specified playlists Request body: ```json { "playlist_ids": [1, 2, 3], "marked_by": "ops-console" } ``` ### `POST /api/playlists/unmark-wanted` Purpose: - remove or disable wanted markers for the specified playlists Request body: ```json { "playlist_ids": [1, 2, 3] } ``` ### `POST /api/playlists/download` Purpose: - create a `download_only` job scoped to selected playlist ids Request body: ```json { "playlist_ids": [1, 2, 3], "requested_by": "ops-console" } ``` Behavior: - create one `download_only` job - store `playlist_scope.playlist_ids = [...]` - do not include playlists that are not selected ### `POST /api/playlists/sync-download` Purpose: - create a `sync_download` job scoped to selected playlist ids Request body: ```json { "playlist_ids": [1, 2, 3], "requested_by": "ops-console" } ``` Behavior: - create one `sync_download` job - store `playlist_scope.playlist_ids = [...]` ## Interaction Rules ### `下载已同步所选歌单` This action should: - create a `download_only` job for the selected `playlist_ids` - operate only on songs already present in `playlist_songs` Playlists in state `未同步` contribute no songs and therefore effectively produce no download work. The UI should make this explicit instead of pretending those playlists are downloading. ### `同步后下载所选歌单` This action should: - create a `sync_download` job for the selected `playlist_ids` - sync playlist songs first - then download only missing songs from those playlists ### Wanted Marker UX The wanted marker is not itself a download state. It is a separate operator-intent flag. A playlist may therefore be: - `已下载` and still marked wanted - `未同步` and marked wanted - `部分已下载` and not marked wanted This separation avoids overloading one column with two different meanings. ## Query and Aggregation Strategy ### Page-First Aggregation Do not compute states for the whole library on each request. Instead: 1. query only the playlist ids for the requested page 2. run aggregation queries only for those playlist ids 3. merge the counts into the returned rows This keeps response cost proportional to current page size instead of full library size. ### Aggregations Needed Per Page For the current page playlist ids: - playlist song totals from `playlist_songs` - downloaded song totals from: - `playlist_songs` - `file_assets` - `file_locations` - `storage_backends` - running download song totals from: - `playlist_songs` - `job_items` - `job_stages` - wanted markers from `playlist_download_preferences` ### Index Expectations Add or verify indexes for: - `pool_playlists(playlist_id)` - `pool_playlists(pool_id)` - `playlist_songs(playlist_id)` - `playlist_songs(song_id)` - `file_assets(song_id)` - `file_locations(file_asset_id, status)` - `job_items(song_id, status)` - `job_stages(id, stage_type)` - `playlist_download_preferences(playlist_id)` - `playlist_download_preferences(is_wanted)` ## Error Handling ### Empty Selection Bulk actions should reject empty `playlist_ids` with a validation error. ### Unknown Playlist IDs If unknown ids are passed: - ignore ids that do not exist - fail only if the final valid set is empty ### Duplicate Playlist IDs Normalize to unique ids before processing. ### Large Selection on One Page The selected ids are page-scoped and therefore bounded by `page_size`. This makes bulk requests predictable and low risk. ## Testing Strategy ### Repository and Query Tests Add tests for: - listing one playlist page with filters - correct `total` count under filtering - wanted marker persistence - state aggregation across: - `未同步` - `未下载` - `下载中` - `部分已下载` - `已下载` ### API Tests Add tests for: - `GET /api/playlists` - `POST /api/playlists/mark-wanted` - `POST /api/playlists/unmark-wanted` - `POST /api/playlists/download` - `POST /api/playlists/sync-download` Verify that: - created jobs use the expected job type - `playlist_scope.playlist_ids` is stored correctly - invalid or empty selection is rejected ### UI Tests At minimum, validate rendered page content and form wiring for: - pagination controls - state filter controls - current-page select-all - bulk action forms - wanted-only filter ### Regression Coverage Keep existing `download_only` and `sync_download` behavior valid for callers outside the playlist page. ## Rollout Notes The recommended rollout order is: 1. add the playlist preference table and repository helpers 2. add page-level playlist listing API with computed state 3. upgrade `/playlists` UI to pagination, filters, selection, and actions 4. add bulk job-creation endpoints for selected playlists 5. verify on NAS with a controlled subset of playlists before using it for wide library download ## Result After this design lands, the operator workflow becomes: 1. open `/playlists` 2. filter to `未同步`, `未下载`, or `部分已下载` 3. select playlists on the current page 4. choose either: - `下载已同步所选歌单` - `同步后下载所选歌单` - `加入待下载清单` 5. observe progress through the existing jobs and worker views This changes playlist download from an uncontrolled whole-library operation into a scoped, inspectable, operator-driven workflow.