Files
musicfetch/docs/superpowers/specs/2026-06-08-playlists-and-profile-hardening-design.md
zebra a24c894c61 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>
2026-06-08 23:38:06 -07:00

5.6 KiB

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