Accept free-form queries (artist/album/title and combos) instead of strict "Artist - Track". Search Lidarr and YouTube Music concurrently, present a unified rich picker with bold keyword matching, and act on the chosen hit. - Normalize results via a Hit dataclass across both sources - Lidarr: /api/v1/search (album+artist) with album/artist lookup fallback - YouTube: ytmusicapi for accurate metadata + music.youtube.com URLs, yt-dlp scrape fallback; tag overrides from the chosen hit - Lidarr album pick adds artist+album monitored, runs interactive release search, and falls through to top YouTube hit when no indexer release exists - argparse CLI: -n/--noninteractive, -s/--ytsearch, -d/--dry-run, -q/--quality, --limit, --lidarr-only/--yt-only, -o/--root, --search-all - Config via LIDARR_URL / LIDARR_API_KEY / MUSICFETCH_ROOT env vars - Update README; add .gitignore for __pycache__ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
618 lines
22 KiB
Python
Executable File
618 lines
22 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
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
import requests
|
|
from requests.exceptions import RequestException, Timeout
|
|
|
|
# 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},
|
|
)
|
|
|
|
|
|
def lidarr_search(query: str, limit: int) -> list[Hit]:
|
|
"""Universal search via /api/v1/search; fall back to album+artist lookup."""
|
|
if not API_KEY:
|
|
err("LIDARR_API_KEY not set — skipping Lidarr search.")
|
|
return []
|
|
hits: list[Hit] = []
|
|
try:
|
|
results = lidarr_get("/api/v1/search", params={"term": query})
|
|
for item in results:
|
|
# /search returns objects with 'foreignId' and either 'album' or 'artist'.
|
|
if item.get("album"):
|
|
hits.append(_album_to_hit(item["album"]))
|
|
elif item.get("artist"):
|
|
hits.append(_artist_to_hit(item["artist"]))
|
|
if hits:
|
|
return hits[:limit]
|
|
dbg("/api/v1/search returned nothing useful; trying lookup endpoints.")
|
|
except Timeout:
|
|
err("Lidarr universal search timed out.")
|
|
except RequestException as e:
|
|
dbg(f"/api/v1/search unavailable ({e}); falling back to lookup endpoints.")
|
|
|
|
# Fallback: album lookup then artist lookup.
|
|
try:
|
|
for album in lidarr_get("/api/v1/album/lookup", params={"term": query}):
|
|
hits.append(_album_to_hit(album))
|
|
except RequestException as e:
|
|
dbg(f"album/lookup failed: {e}")
|
|
try:
|
|
for artist in lidarr_get("/api/v1/artist/lookup", params={"term": query}):
|
|
hits.append(_artist_to_hit(artist))
|
|
except RequestException as e:
|
|
dbg(f"artist/lookup 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:
|
|
cmd += ["--replace-in-metadata", "artist", ".*", hit.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()
|