diff --git a/docs/superpowers/specs/2026-06-08-playlists-and-profile-hardening-design.md b/docs/superpowers/specs/2026-06-08-playlists-and-profile-hardening-design.md new file mode 100644 index 0000000..538d215 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-playlists-and-profile-hardening-design.md @@ -0,0 +1,113 @@ +# YouTube Playlists + Lidarr Profile Hardening — Design + +**Date:** 2026-06-08 +**Status:** Approved + +## Context & Goal + +Two additions to `musicfetch` (and its REST API): + +1. **Lidarr profile hardening.** `add_artist` currently hardcodes + `qualityProfileId=1` and `get_default_metadata_profile_id()` returns + `profiles[0]["id"]` — the *first* profile, arbitrarily. If a user's profile + order differs, every added artist could silently get the wrong metadata + profile (e.g. **OST** or **None**) or quality profile. Select by name with an + env override instead. + +2. **YouTube playlist support.** A playlist URL should download **each track as + its own file**, routed into per-track artist folders, via both the CLI and the + REST API (one playlist = one job). + +## Decisions (confirmed with user) + +- Playlists work in **CLI + REST API**; one playlist = **one job** (option B). +- Tracks land in **per-track artist folders** `//youtube/` (option A), + reusing the existing single-track path. +- Partial failures: job is **`done` if ≥1 track succeeded** (message + `"Downloaded 12/14 tracks (2 failed)"`); `failed` only if zero succeed. +- Playlist expansion prefers **ytmusicapi** for YT Music playlists, falls back to + **`yt-dlp --flat-playlist`**; each track then downloads through the existing + `act_youtube` (first-artist folders, tag overrides, `music.youtube` URLs). +- A `watch?v=X&list=Y` URL stays a **single track** (no surprise batch); only a + pure playlist URL (`/playlist?list=` or `list=` without `v=`) triggers a batch. + +## Architecture + +### Feature 1 — Profile hardening (`musicfetch`) + +- `get_default_metadata_profile_id()` → look up `/api/v1/metadataprofile`, pick the + profile whose name matches env `LIDARR_METADATA_PROFILE` (default `"Standard"`, + case-insensitive); fall back to the first profile's id, then `1`. +- New `get_quality_profile_id()` → `/api/v1/qualityprofile`, match env + `LIDARR_QUALITY_PROFILE` (default `"Any"`); fall back to first id, then `1`. +- `add_artist` uses `get_quality_profile_id()` instead of literal `1`. + +### Feature 2 — Playlists + +New units in `musicfetch`: + +``` +is_playlist_url(url) -> bool +expand_playlist(url) -> tuple[str, list[Hit]] # (playlist_title, track Hits) +download_playlist(url, root, quality, dry_run) -> tuple[int, int] # (ok, total) +``` + +- **`is_playlist_url`**: true when the URL has `list=` AND no `v=` param, or path + contains `/playlist`. (So `watch?v=...&list=...` → False.) +- **`expand_playlist`**: if `ytmusicapi` is available and the URL is a YT Music + playlist, use `YTMusic().get_playlist(playlist_id)` → tracks with + artist/album/year/videoId. Otherwise `yt-dlp --flat-playlist -J ` → entries + (title, uploader→artist, id). Map each to `Hit(source="youtube", kind="track", + …, payload={"videoId": id})`. Returns the playlist title + Hits. Empty/none → `("", [])`. +- **`download_playlist`**: for each Hit call `act_youtube(hit, root, quality, + dry_run)`, catching per-track exceptions (count ok/total); returns `(ok, total)`. +- **`handle_url`** (CLI): if `is_playlist_url` → `download_playlist` and print + `"Downloaded N/M tracks"`; else the existing single-URL download. + +### Feature 2 — REST API (`server/`) + +`POST /fetch` currently treats `q` only as a search term. Add URL routing: + +- In `server/app.py` `fetch()`: if `mf.is_url(q)` → create a **download job** + (not a search). The job runs in `server/actions.py`: + - `is_playlist_url(q)` → `download_playlist(q, ROOT, quality, False)` → result + `{"ok": n, "total": m, "path": None, "lidarr_album_id": None}`; message + `"Downloaded {n}/{m} tracks"` (+ ` ({m-n} failed)` when failures). + - else single URL → reuse `handle_url`-equivalent single download; message + `"Downloaded ''"`. +- Response `hit` for a playlist: `{"source":"youtube","kind":"playlist", + "title": <playlist title>, "artist":"", "album":"", "year":""}`. +- Status: `done` if `ok ≥ 1` (or single URL succeeded), else `failed`. +- `actions.perform_fetch` (search path) is unchanged; a new + `actions.perform_url_fetch(q, quality, root) -> dict` handles the URL branch, and + `started_message`/`done_message` get URL/playlist-aware variants. + +## Error Handling + +- Per-track download failures in `download_playlist` are caught and counted; the + batch continues. A batch with zero successes → job `failed`. +- `expand_playlist` degrades ytmusicapi → yt-dlp → `("", [])`; an empty expansion + yields a `failed` job with message `"No tracks found in playlist."`. +- Profile lookups already degrade to a sane fallback id on any HTTP error. + +## Testing + +Unit (mock network / `act_youtube`, no real downloads): +- `is_playlist_url`: `/playlist?list=…` → True; `watch?v=X&list=Y` → False; + `watch?v=X` → False; non-URL → False. +- `get_default_metadata_profile_id` / `get_quality_profile_id`: pick by env name; + fall back to first when name absent; fall back to `1` on error. +- `expand_playlist`: maps ytmusicapi playlist JSON → Hits (title/artist/videoId); + yt-dlp fallback path; empty → `("", [])`. +- `download_playlist`: counts ok/total with one track's `act_youtube` raising. +- API: `POST /fetch` with a playlist URL → job, batch message `"Downloaded n/m…"`, + `done` when ok≥1; single URL → single-download job; zero-success → `failed`. + +Live check: a small real YT Music playlist (3-5 tracks) → each track lands in +`<root>/<artist>/youtube/` with correct single-artist tags; job message reports +`N/M`. + +## Out of Scope (YAGNI) + +Per-track job fan-out (option C), resume/skip-already-downloaded, playlist→Lidarr +album matching, dedup across runs, progress streaming during a batch.