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