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:
@@ -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.
|
||||||
Reference in New Issue
Block a user