feat: multi-platform URL & playlist support via yt-dlp probe

Generalize URL handling beyond YouTube to any yt-dlp-supported site
(SoundCloud, Bandcamp, etc), single tracks and playlists/sets/albums.

- probe_url(): one yt-dlp --flat-playlist probe classifies playlist vs track
  and returns per-entry Hits; YouTube playlists still use ytmusicapi.
- _track_url(): YouTube tracks keep the music.youtube album-art URL; other
  platforms download via their native entry URL (no more videoId reconstruction).
- Per-source folders: <root>/<artist>/<extractor>/ (soundcloud/bandcamp/youtube)
  instead of hardcoded youtube; download_single derives source from metadata.
- download_hits() downloads pre-probed Hits; API probes once and passes hits
  into the job closure. Replaces YouTube-only is_playlist_url/expand_playlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 00:25:58 -07:00
parent 7309ad3a29
commit f103b6c253
8 changed files with 260 additions and 102 deletions

View File

@@ -81,11 +81,11 @@ def url_done_message(result: dict) -> str:
return f"Downloaded '{title}'." if title else "Download complete."
def perform_url_fetch(url: str, quality: str, root: str) -> dict:
"""Download a URL (playlist -> batch, else single). Raises if nothing
downloaded so the job is marked failed."""
if mf.is_playlist_url(url):
ok, total, title = mf.download_playlist(url, root, quality, False)
def perform_url_fetch(url: str, kind: str, title: str, hits: list, quality: str, root: str) -> dict:
"""Download a probed URL (playlist -> batch over pre-probed hits, else single).
Raises if nothing downloaded so the job is marked failed."""
if kind == "playlist":
ok, total = mf.download_hits(hits, root, quality, False)
if ok == 0:
raise RuntimeError(f"No tracks downloaded from playlist '{title}'." if title
else "No tracks downloaded from playlist.")

View File

@@ -53,14 +53,14 @@ def fetch(q: str = Query(..., min_length=1),
raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.")
if mf.is_url(q):
kind = "playlist" if mf.is_playlist_url(q) else "track"
syn = mf.Hit(source="youtube", kind=kind, title="", artist="")
job = jobs.create_job(hit=syn, message=actions.url_started_message(kind))
kind, title, hits = mf.probe_url(q)
syn = mf.Hit(source="youtube", kind=kind, title=title, artist="")
job = jobs.create_job(hit=syn, message=actions.url_started_message(kind, title))
response = _job_public(job)
done_msg = actions.playlist_done_message if kind == "playlist" else actions.url_done_message
jobs.run_job(
job.id,
lambda: actions.perform_url_fetch(q, quality, ROOT),
lambda: actions.perform_url_fetch(q, kind, title, hits, quality, ROOT),
done_message=done_msg,
fail_message="Download failed.",
)

View File

@@ -25,10 +25,10 @@ act_lidarr_album = _mod.act_lidarr_album
act_lidarr_artist = _mod.act_lidarr_artist
QUALITY_CHOICES = _mod.QUALITY_CHOICES
is_url = _mod.is_url
is_playlist_url = _mod.is_playlist_url
download_playlist = _mod.download_playlist
probe_url = _mod.probe_url
download_hits = _mod.download_hits
download_single = _mod.download_single
__all__ = ["Hit", "build_combined_hits", "pick", "act_youtube",
"act_lidarr_album", "act_lidarr_artist", "QUALITY_CHOICES",
"is_url", "is_playlist_url", "download_playlist", "download_single"]
"is_url", "probe_url", "download_hits", "download_single"]