# 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.