Files
musicfetch/musicfetch
2026-06-08 23:30:15 -07:00

734 lines
27 KiB
Python
Executable File

#!/usr/bin/env python3
"""MusicFetch v2 — fetch music via Lidarr (preferred) or YouTube Music (yt-dlp).
Accepts a free-form query ("artist", "title", "album", or combos like
"artist - title" / "artist - album") or a URL. Searches Lidarr and YouTube
Music concurrently, shows the top hits in an interactive picker, and acts on
the chosen hit. See README.md for full docs.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from typing import Optional
import requests
from requests.exceptions import RequestException
# Optional deps — degrade gracefully if missing.
try:
from ytmusicapi import YTMusic
except ImportError:
YTMusic = None
try:
from rich.console import Console
from rich.table import Table
from rich.text import Text
_console = Console()
except ImportError:
Console = None
_console = None
# === CONFIGURATION ===
LIDARR_URL = os.environ.get("LIDARR_URL", "http://localhost:8686").rstrip("/")
API_KEY = os.environ.get("LIDARR_API_KEY", "")
DEFAULT_ROOT = os.environ.get("MUSICFETCH_ROOT", "/media/music")
HEADERS = {"X-Api-Key": API_KEY, "Content-Type": "application/json"}
# Runtime flags, populated in main().
DEBUG = False
# Quality choices for --quality.
QUALITY_CHOICES = ["best", "320", "m4a", "opus", "flac"]
def dbg(*a):
if DEBUG:
print("[DEBUG]", *a)
def err(*a):
print("[ERROR]", *a, file=sys.stderr)
# ---------------------------------------------------------------------------
# Hit model
# ---------------------------------------------------------------------------
@dataclass
class Hit:
source: str # "lidarr" | "youtube"
kind: str # "artist" | "album" | "track"
title: str = "" # track/album title (display)
artist: str = ""
album: str = ""
year: str = ""
thumbnail: str = ""
payload: dict = field(default_factory=dict) # raw data needed to act
@property
def display_title(self) -> str:
return self.title or self.album or self.artist
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def is_url(s: str) -> bool:
return bool(re.match(r"https?://", s))
def lidarr_get(path, params=None, timeout=15):
resp = requests.get(f"{LIDARR_URL}{path}", headers=HEADERS, params=params, timeout=timeout)
resp.raise_for_status()
return resp.json()
def lidarr_post(path, payload, timeout=15):
resp = requests.post(f"{LIDARR_URL}{path}", headers=HEADERS, json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json() if resp.content else {}
# ---------------------------------------------------------------------------
# Lidarr search
# ---------------------------------------------------------------------------
def _year_from_album(album: dict) -> str:
rd = album.get("releaseDate") or album.get("firstReleaseDate") or ""
return rd[:4] if rd else ""
def _album_to_hit(album: dict) -> Hit:
artist = (album.get("artist") or {}).get("artistName") or album.get("artistName") or ""
return Hit(
source="lidarr",
kind="album",
title=album.get("title", ""),
artist=artist,
album=album.get("title", ""),
year=_year_from_album(album),
payload={"album": album},
)
def _artist_to_hit(artist: dict) -> Hit:
return Hit(
source="lidarr",
kind="artist",
title=artist.get("artistName") or artist.get("title", ""),
artist=artist.get("artistName") or artist.get("title", ""),
payload={"artist": artist},
)
MUSICBRAINZ_URL = "https://musicbrainz.org/ws/2"
MB_HEADERS = {"User-Agent": "musicfetch/2.0 (https://github.com/; personal music fetcher)"}
_mb_last_call = 0.0
def _mb_rate_limit():
"""Courtesy ~1 req/sec to MusicBrainz."""
global _mb_last_call
elapsed = time.time() - _mb_last_call
if elapsed < 1.0:
time.sleep(1.0 - elapsed)
_mb_last_call = time.time()
def _mb_artist_credit(credit) -> str:
"""First credited artist name only (ignore featured/secondary)."""
if credit and isinstance(credit, list) and isinstance(credit[0], dict):
return credit[0].get("name") or (credit[0].get("artist") or {}).get("name", "")
return ""
def musicbrainz_best_album(artist: str, track: str, timeout: int = 8) -> Optional[dict]:
"""Resolve 'artist - track' to its best studio album via MusicBrainz.
Prefers a studio album credited to the track's own artist (not a Various
Artists compilation). Returns {album_title, artist, year, rg_mbid} or None.
Never raises."""
query = f'artist:"{artist}" AND recording:"{track}"'
try:
_mb_rate_limit()
resp = requests.get(
f"{MUSICBRAINZ_URL}/recording",
params={"query": query, "fmt": "json", "limit": 25},
headers=MB_HEADERS, timeout=timeout,
)
resp.raise_for_status()
data = resp.json()
except Exception as e: # noqa: BLE001 — degrade to fallback on any failure
dbg(f"MusicBrainz lookup failed: {e}")
return None
# candidate = (own_studio, is_studio, date_sortkey, title, artist, year, mbid)
candidates = []
for rec in data.get("recordings", []):
rec_artist = _mb_artist_credit(rec.get("artist-credit"))
for rel in rec.get("releases", []):
rg = rel.get("release-group") or {}
title = rg.get("title") or rel.get("title") or ""
if not title:
continue
mbid = rg.get("id") or ""
primary = rg.get("primary-type") or ""
secondary = rg.get("secondary-types") or []
rel_artist = _mb_artist_credit(rel.get("artist-credit"))
date = rel.get("date") or rg.get("first-release-date") or ""
is_studio = primary == "Album" and not secondary
own_studio = is_studio and (
not rel_artist or rel_artist.casefold() == rec_artist.casefold()
)
candidates.append((own_studio, is_studio, date or "9999", title, rec_artist, date[:4], mbid))
if not candidates:
return None
pool = ([c for c in candidates if c[0]]
or [c for c in candidates if c[1]]
or candidates)
pool.sort(key=lambda c: c[2]) # earliest date first
_, _, _, title, art, year, mbid = pool[0]
dbg(f"MusicBrainz resolved '{artist} - {track}' -> '{title}' ({year}) mbid={mbid}")
return {"album_title": title, "artist": art or artist, "year": year, "rg_mbid": mbid}
def _split_query(query: str) -> tuple[str, Optional[str]]:
"""Split a Shazam-style 'Artist - Track' on the first ' - '.
Returns (artist, track) or (term, None) when there is no separator."""
if " - " in query:
left, right = query.split(" - ", 1)
return left.strip(), right.strip()
return query.strip(), None
def lidarr_search(query: str, limit: int) -> list[Hit]:
"""Return Lidarr hits, best match first. Resolves 'Artist - Track' to an
album's MusicBrainz release-group MBID, then does an exact Lidarr lookup
(term=mbid:<id>) — no fuzzy ranking. Falls back so it never raises and
returns [] only on total failure / missing key."""
if not API_KEY:
err("LIDARR_API_KEY not set — skipping Lidarr search.")
return []
artist, right = _split_query(query)
if right:
mb = musicbrainz_best_album(artist, right)
if mb and mb["rg_mbid"]:
hits = _lidarr_album_candidates(f"mbid:{mb['rg_mbid']}")
for h in hits:
if not h.year and mb["year"]:
h.year = mb["year"]
if hits:
return hits[:limit]
# MusicBrainz miss / no exact album → plain lookup (album-first: a dash
# query named an album/track).
return _fallback_lookup(query, limit, artist_first=False)
# Bare term is most often an artist.
return _fallback_lookup(query, limit, artist_first=True)
def _lidarr_album_candidates(term: str) -> list[Hit]:
try:
return [_album_to_hit(a) for a in lidarr_get("/api/v1/album/lookup", params={"term": term})]
except RequestException as e:
dbg(f"album/lookup failed: {e}")
return []
def _lidarr_artist_candidates(term: str) -> list[Hit]:
try:
return [_artist_to_hit(a) for a in lidarr_get("/api/v1/artist/lookup", params={"term": term})]
except RequestException as e:
dbg(f"artist/lookup failed: {e}")
return []
def _fallback_lookup(query: str, limit: int, artist_first: bool) -> list[Hit]:
"""Plain album + artist lookups (no scoring); /search as last resort."""
albums = _lidarr_album_candidates(query)
artists = _lidarr_artist_candidates(query)
hits = (artists + albums) if artist_first else (albums + artists)
if hits:
return hits[:limit]
return _universal_search(query, limit)
def _universal_search(query: str, limit: int) -> list[Hit]:
"""Last resort: Lidarr's fuzzy /search (unranked)."""
hits: list[Hit] = []
try:
for item in lidarr_get("/api/v1/search", params={"term": query}):
if item.get("album"):
hits.append(_album_to_hit(item["album"]))
elif item.get("artist"):
hits.append(_artist_to_hit(item["artist"]))
except RequestException as e:
dbg(f"/api/v1/search failed: {e}")
return hits[:limit]
# ---------------------------------------------------------------------------
# YouTube search (ytmusicapi preferred, yt-dlp scrape fallback)
# ---------------------------------------------------------------------------
def _ytm_thumb(item: dict) -> str:
thumbs = item.get("thumbnails") or []
return thumbs[-1]["url"] if thumbs else ""
def _ytm_artists(item: dict) -> str:
arts = item.get("artists") or []
return ", ".join(a.get("name", "") for a in arts if a.get("name"))
def youtube_search(query: str, limit: int) -> list[Hit]:
if YTMusic is not None:
try:
return _ytmusic_search(query, limit)
except Exception as e: # ytmusicapi raises broadly
dbg(f"ytmusicapi search failed ({e}); falling back to yt-dlp scrape.")
return _ytdlp_search(query, limit)
def _ytmusic_search(query: str, limit: int) -> list[Hit]:
yt = YTMusic()
hits: list[Hit] = []
# Songs give us videoId + album + artist; that's the best download target.
for item in yt.search(query, filter="songs", limit=limit):
vid = item.get("videoId")
if not vid:
continue
album = (item.get("album") or {}).get("name", "") if isinstance(item.get("album"), dict) else (item.get("album") or "")
hits.append(Hit(
source="youtube",
kind="track",
title=item.get("title", ""),
artist=_ytm_artists(item),
album=album,
year=str(item.get("year") or ""),
thumbnail=_ytm_thumb(item),
payload={"videoId": vid},
))
if len(hits) >= limit:
break
return hits
def _ytdlp_search(query: str, limit: int) -> list[Hit]:
try:
result = subprocess.run(
["yt-dlp", "--flat-playlist", "-J", f"ytsearch{limit}:{query}"],
capture_output=True, text=True, check=True,
)
data = json.loads(result.stdout)
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
err(f"yt-dlp search failed: {e}")
return []
hits: list[Hit] = []
for entry in data.get("entries", []):
vid = entry.get("id")
if not vid:
continue
hits.append(Hit(
source="youtube",
kind="track",
title=entry.get("title", ""),
artist=entry.get("uploader") or entry.get("channel") or "",
year="",
thumbnail="",
payload={"videoId": vid},
))
return hits[:limit]
# ---------------------------------------------------------------------------
# Picker / rendering
# ---------------------------------------------------------------------------
def _keywords(query: str) -> list[str]:
return [w for w in re.split(r"[\s\-]+", query.lower()) if len(w) > 1]
def _ansi_bold_match(s: str, keywords: list[str]) -> str:
if not s:
return ""
out = s
for kw in keywords:
out = re.sub(f"({re.escape(kw)})", "\033[1m\\1\033[0m", out, flags=re.IGNORECASE)
return out
def _rich_match(s: str, keywords: list[str]):
text = Text(s or "")
low = (s or "").lower()
for kw in keywords:
start = 0
while True:
idx = low.find(kw, start)
if idx == -1:
break
text.stylize("bold", idx, idx + len(kw))
start = idx + len(kw)
return text
def render_picker(hits: list[Hit], query: str, yt_first: bool) -> None:
keywords = _keywords(query)
if _console is not None:
table = Table(show_lines=False, expand=False)
table.add_column("#", justify="right", style="cyan")
table.add_column("Src")
table.add_column("Artist")
table.add_column("Album / Title")
table.add_column("Year")
table.add_column("Type")
for i, h in enumerate(hits, 1):
src = "[green]LID[/]" if h.source == "lidarr" else "[red]YT[/]"
at = h.album if h.kind == "album" else h.display_title
table.add_row(
str(i), src,
_rich_match(h.artist, keywords),
_rich_match(at, keywords),
h.year, h.kind,
)
_console.print(table)
else:
for i, h in enumerate(hits, 1):
src = "LID" if h.source == "lidarr" else "YT "
at = h.album if h.kind == "album" else h.display_title
print(f"{i:>3} {src} {_ansi_bold_match(h.artist, keywords):<30} "
f"{_ansi_bold_match(at, keywords):<40} {h.year:<6} {h.kind}")
def pick(hits: list[Hit], query: str, noninteractive: bool, yt_first: bool) -> Optional[Hit]:
if not hits:
return None
if noninteractive:
primary = "youtube" if yt_first else "lidarr"
for h in hits:
if h.source == primary:
return h
return hits[0]
render_picker(hits, query, yt_first)
while True:
try:
raw = input("Pick a number (q to quit): ").strip()
except (EOFError, KeyboardInterrupt):
print()
return None
if raw.lower() in ("q", "quit", ""):
return None
if raw.isdigit() and 1 <= int(raw) <= len(hits):
return hits[int(raw) - 1]
print("Invalid choice.")
# ---------------------------------------------------------------------------
# Lidarr actions
# ---------------------------------------------------------------------------
def get_existing_artist(name: str) -> Optional[dict]:
try:
for artist in lidarr_get("/api/v1/artist", timeout=10):
if artist.get("artistName", "").lower() == name.lower():
return artist
except RequestException as e:
dbg(f"existing artist check failed: {e}")
return None
def get_default_metadata_profile_id() -> int:
try:
profiles = lidarr_get("/api/v1/metadataprofile", timeout=10)
if profiles:
return profiles[0]["id"]
except RequestException as e:
dbg(f"metadataprofile fetch failed: {e}")
return 1
def add_artist(meta: dict, root: str, search_all: bool, dry_run: bool) -> Optional[dict]:
foreign_id = meta.get("foreignArtistId") or meta.get("id")
name = meta.get("artistName") or meta.get("title")
if not foreign_id or not name:
err("Missing foreignArtistId/artistName; cannot add artist.")
return None
payload = {
"foreignArtistId": foreign_id,
"artistName": name,
"qualityProfileId": 1,
"metadataProfileId": get_default_metadata_profile_id(),
"rootFolderPath": root,
"monitored": True,
"addOptions": {"searchForMissingAlbums": search_all, "monitor": "all"},
}
if dry_run:
print(f"[dry-run] POST /api/v1/artist {json.dumps(payload)}")
return {"id": -1, "artistName": name, **payload}
try:
return lidarr_post("/api/v1/artist", payload)
except RequestException as e:
err(f"add_artist failed: {e}")
return None
def ensure_album_in_library(album: dict, root: str, search_all: bool, dry_run: bool) -> Optional[dict]:
"""Return a library album dict (with numeric id). Adds artist if needed."""
# Already in library?
if album.get("id") and isinstance(album.get("id"), int) and album.get("id") > 0 and not album.get("foreignAlbumId", "").startswith("lookup"):
# Heuristic: lookup results carry a 0/None id; library albums carry real ids.
if album.get("artistId"):
return album
artist_obj = album.get("artist") or {}
artist_name = artist_obj.get("artistName") or album.get("artistName") or ""
existing = get_existing_artist(artist_name) if artist_name else None
if not existing:
print(f"Adding artist '{artist_name}' to Lidarr...")
existing = add_artist(artist_obj or {"artistName": artist_name,
"foreignArtistId": artist_obj.get("foreignArtistId")},
root, search_all, dry_run)
if not existing:
return None
if dry_run:
print(f"[dry-run] would resolve album '{album.get('title')}' under artist id {existing.get('id')}")
return {**album, "id": album.get("id") or -1, "artistId": existing.get("id")}
# Find the album in the (now-present) artist's albums by title match.
try:
albums = lidarr_get("/api/v1/album", params={"artistId": existing["id"]}, timeout=15)
for a in albums:
if a.get("title", "").lower() == album.get("title", "").lower():
return a
if albums:
return albums[0]
except RequestException as e:
dbg(f"album list fetch failed: {e}")
return None
def release_available(album_id: int) -> bool:
"""Interactive search: does any indexer have a release for this album?"""
try:
releases = lidarr_get("/api/v1/release", params={"albumId": album_id}, timeout=90)
dbg(f"interactive search returned {len(releases)} releases for album {album_id}")
return len(releases) > 0
except RequestException as e:
dbg(f"release search failed: {e}")
return False
def trigger_album_search(album_id: int, dry_run: bool):
if dry_run:
print(f"[dry-run] POST /api/v1/command AlbumSearch albumIds=[{album_id}]")
return
lidarr_post("/api/v1/command", {"name": "AlbumSearch", "albumIds": [album_id]})
def act_lidarr_album(hit: Hit, root: str, search_all: bool, dry_run: bool) -> bool:
"""Returns True if Lidarr handled it; False to fall through to YouTube."""
album = hit.payload["album"]
lib_album = ensure_album_in_library(album, root, search_all, dry_run)
if not lib_album:
err("Could not resolve album in Lidarr.")
return False
album_id = lib_album.get("id")
if dry_run:
print(f"[dry-run] would interactive-search album id {album_id}; "
f"if no release found, fall through to YouTube.")
trigger_album_search(album_id, dry_run)
return True
if isinstance(album_id, int) and album_id > 0 and release_available(album_id):
print(f"Indexer release available — triggering Lidarr grab for '{hit.album}'.")
trigger_album_search(album_id, dry_run)
return True
print("No indexer release found in Lidarr — falling through to YouTube.")
return False
def act_lidarr_artist(hit: Hit, root: str, search_all: bool, dry_run: bool) -> bool:
artist = hit.payload["artist"]
print(f"Adding artist '{hit.artist}' to Lidarr...")
result = add_artist(artist, root, search_all, dry_run)
return result is not None
# ---------------------------------------------------------------------------
# YouTube download
# ---------------------------------------------------------------------------
def _quality_args(quality: str) -> list[str]:
if quality == "best":
# bestaudio, prefer mp3 320 only if extraction needs a container.
return ["-f", "bestaudio/best", "-x", "--audio-quality", "0"]
if quality == "320":
return ["-f", "bestaudio/best", "-x", "--audio-format", "mp3", "--audio-quality", "0"]
if quality in ("m4a", "opus", "flac"):
return ["-f", "bestaudio/best", "-x", "--audio-format", quality, "--audio-quality", "0"]
return ["-f", "bestaudio/best", "-x"]
def yt_download(url_or_query: str, target_folder: str, quality: str, dry_run: bool,
hit: Optional[Hit] = None):
cmd = ["yt-dlp",
*_quality_args(quality),
"--embed-metadata",
"--embed-thumbnail",
"--no-playlist",
"-P", target_folder]
# Override tags from the chosen hit so they don't rely on scraped titles.
if hit:
if hit.artist:
# First artist only; anchored ^.*$ replaces the whole field exactly once
# (a bare .* matches twice and doubles the value).
primary_artist = hit.artist.split(",")[0].strip()
cmd += ["--replace-in-metadata", "artist", "^.*$", primary_artist]
if hit.album:
cmd += ["--parse-metadata", f"{hit.album}:%(album)s"]
if hit.title:
cmd += ["--parse-metadata", f"{hit.title}:%(title)s"]
if hit.year:
cmd += ["--parse-metadata", f"{hit.year}:%(release_year)s"]
cmd.append(url_or_query)
if dry_run:
print(f"[dry-run] mkdir -p {target_folder}")
print(f"[dry-run] {' '.join(cmd)}")
return
os.makedirs(target_folder, exist_ok=True)
print(f"Downloading via yt-dlp -> {target_folder}")
subprocess.run(cmd)
def act_youtube(hit: Hit, root: str, quality: str, dry_run: bool):
vid = hit.payload.get("videoId")
# Prefer YouTube Music URL for correct album art / topic metadata.
url = f"https://music.youtube.com/watch?v={vid}" if vid else f"ytsearch1:{hit.artist} {hit.title}"
artist_dir = hit.artist.split(",")[0].strip() or "Unknown Artist"
target = os.path.join(root, artist_dir, "youtube")
yt_download(url, target, quality, dry_run, hit=hit)
# ---------------------------------------------------------------------------
# URL path
# ---------------------------------------------------------------------------
def run_yt_dlp_get_metadata(url: str) -> Optional[dict]:
try:
result = subprocess.run(
["yt-dlp", "-j", "--no-playlist", url],
capture_output=True, text=True, check=True,
)
return json.loads(result.stdout)
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
err(f"yt-dlp metadata extraction failed: {e}")
return None
def get_artist_from_metadata(meta: dict) -> str:
for key in ("artist", "creator", "uploader", "channel"):
if meta.get(key):
return meta[key]
if "title" in meta and " - " in meta["title"]:
return meta["title"].split(" - ", 1)[0].strip()
return "Unknown Artist"
def handle_url(url: str, root: str, quality: str, dry_run: bool):
meta = run_yt_dlp_get_metadata(url)
artist = get_artist_from_metadata(meta) if meta else "Unknown Artist"
target = os.path.join(root, artist, "youtube")
yt_download(url, target, quality, dry_run)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def build_combined_hits(query, limit, yt_first, lidarr_only, yt_only) -> list[Hit]:
lidarr_hits: list[Hit] = []
yt_hits: list[Hit] = []
with ThreadPoolExecutor(max_workers=2) as ex:
f_lid = None if yt_only else ex.submit(lidarr_search, query, limit)
f_yt = None if lidarr_only else ex.submit(youtube_search, query, limit)
if f_lid:
lidarr_hits = f_lid.result()
if f_yt:
yt_hits = f_yt.result()
return (yt_hits + lidarr_hits) if yt_first else (lidarr_hits + yt_hits)
def parse_args():
p = argparse.ArgumentParser(
prog="musicfetch",
description="Fetch music via Lidarr (preferred) or YouTube Music.")
p.add_argument("query", nargs="+", help="Free-form query or a URL.")
p.add_argument("-n", "--noninteractive", action="store_true",
help="Auto-pick the top hit, no prompt.")
p.add_argument("-s", "--ytsearch", action="store_true",
help="YouTube first instead of Lidarr first.")
p.add_argument("-d", "--dry-run", action="store_true",
help="Show actions without executing them.")
p.add_argument("-q", "--quality", choices=QUALITY_CHOICES, default="best",
help="Audio quality/format (default: best).")
p.add_argument("--limit", type=int, default=10, help="Hits per source (default 10).")
p.add_argument("--lidarr-only", action="store_true", help="Skip YouTube.")
p.add_argument("--yt-only", action="store_true", help="Skip Lidarr.")
p.add_argument("-o", "--root", default=DEFAULT_ROOT, help=f"Output root (default {DEFAULT_ROOT}).")
p.add_argument("--search-all", action="store_true",
help="Search all albums when adding an artist to Lidarr.")
p.add_argument("--debug", action="store_true", help="Verbose output.")
return p.parse_args()
def main():
global DEBUG
args = parse_args()
DEBUG = args.debug
query = " ".join(args.query).strip()
if args.lidarr_only and args.yt_only:
err("--lidarr-only and --yt-only are mutually exclusive.")
sys.exit(1)
if is_url(query):
handle_url(query, args.root, args.quality, args.dry_run)
return
hits = build_combined_hits(query, args.limit, args.ytsearch,
args.lidarr_only, args.yt_only)
if not hits:
print("No hits found from any source.")
sys.exit(1)
chosen = pick(hits, query, args.noninteractive, args.ytsearch)
if not chosen:
print("Nothing selected.")
return
if chosen.source == "lidarr":
if chosen.kind == "album":
handled = act_lidarr_album(chosen, args.root, args.search_all, args.dry_run)
if not handled and not args.lidarr_only:
# Fall through to the top YouTube hit for the same query.
yt_fallback = next((h for h in hits if h.source == "youtube"), None)
if yt_fallback:
print("Using top YouTube hit as fallback.")
act_youtube(yt_fallback, args.root, args.quality, args.dry_run)
else:
print("No YouTube fallback available.")
else:
act_lidarr_artist(chosen, args.root, args.search_all, args.dry_run)
else:
act_youtube(chosen, args.root, args.quality, args.dry_run)
if __name__ == "__main__":
main()