Add design spec: YouTube playlists + Lidarr profile hardening

Playlists download each track to per-track artist folders (CLI + REST API,
one job per playlist, done if >=1 track succeeds). Profile selection by name
with env overrides (LIDARR_METADATA_PROFILE/LIDARR_QUALITY_PROFILE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:38:06 -07:00
parent a424fbfd2f
commit a24c894c61

View File

@@ -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** `<root>/<artist>/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 <url>` → 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 '<title>'"`.
- 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.