feat: rework Lidarr library integration

This commit is contained in:
2026-10-04 20:28:24 -07:00
parent 880c2217b1
commit b3e6f66097
17 changed files with 1839 additions and 127 deletions

View File

@@ -18,16 +18,62 @@ def _primary_artist(hit) -> str:
return (hit.artist.split(",")[0].strip() if hit.artist else "") or "unknown artist"
MAX_CHOICES = int(os.environ.get("MUSICFETCH_MAX_CHOICES", "10"))
class FetchError(RuntimeError):
"""A failure whose text is written for a person to hear. Anything else
fails with the job's generic message, because a yt-dlp stack trace is not
something Siri should read out."""
def started_message(hit) -> str:
return f"Found '{_title(hit)}' by {_primary_artist(hit)} on {_source_label(hit)}. Downloading now."
"""What the server is about to do — never a claim that it finished."""
artist = _primary_artist(hit)
if hit.source == "lidarr":
if hit.kind == "artist":
return f"Looking up {artist} in Lidarr."
return (f"Found the album '{_title(hit)}' by {artist} in Lidarr. "
f"Searching indexers now.")
return f"Found '{_title(hit)}' by {artist} on {_source_label(hit)}. Downloading now."
def done_message(hit) -> str:
return f"Finished downloading '{_title(hit)}' by {_primary_artist(hit)}."
def _artist_line(state: str, artist: str) -> str:
if state == "added":
return f"Added {artist} to Lidarr."
if state == "existing":
return f"{artist} is already in Lidarr."
return ""
def done_message(hit, result: "dict | None" = None) -> str:
"""Report what really happened. An artist add is not a download, and a
Lidarr grab is a hand-off to the download client, not a finished file."""
artist, title = _primary_artist(hit), _title(hit)
outcome = (result or {}).get("outcome", "")
if outcome == "artist_added":
return (f"Added {artist} to Lidarr. Lidarr now monitors their albums. "
f"Nothing was downloaded.")
if outcome == "artist_existing":
return f"{artist} is already in Lidarr. Nothing was added."
if outcome == "album_grabbed":
return grab_message(result or {}, artist=artist, album=title)
if outcome == "no_release":
lead = _artist_line(result.get("artist_state", ""), artist)
return (f"{lead} '{title}' is monitored in Lidarr, but no indexer has a "
f"release for it right now.").strip()
if outcome == "youtube_fallback":
return (f"Lidarr could not get '{title}' by {artist}. "
f"Downloaded it from YouTube Music instead.")
return f"Finished downloading '{title}' by {artist}."
def failed_message(hit) -> str:
return f"Failed to download '{_title(hit)}' by {_primary_artist(hit)}."
artist, title = _primary_artist(hit), _title(hit)
if hit.source == "lidarr" and hit.kind == "artist":
return f"Could not add {artist} to Lidarr."
return f"Failed to fetch '{title}' by {artist}."
def _yt_path(hit, root: str) -> str:
@@ -35,32 +81,56 @@ def _yt_path(hit, root: str) -> str:
return os.path.join(root, artist_dir, "youtube")
def _download_youtube(hit, quality: str, root: str) -> dict:
def _download_youtube(hit, quality: str, root: str, outcome: str = "youtube") -> dict:
mf.act_youtube(hit, root, quality, False)
return {"path": _yt_path(hit, root), "lidarr_album_id": None}
return {"outcome": outcome, "path": _yt_path(hit, root), "lidarr_album_id": None}
def perform_fetch(chosen, hits: list, quality: str, root: str) -> dict:
"""Run the download for the chosen hit. Returns {"path", "lidarr_album_id"}.
Raises on unrecoverable failure (recorded by the job worker)."""
def perform_fetch(chosen, hits: list, quality: str, root: str,
auto: bool = False, allow_youtube: bool = True) -> dict:
"""Act on the chosen hit. Returns a result dict; a non-empty "choices" list
parks the job so the client can pick a release. Raises on unrecoverable
failure (recorded by the job worker).
`auto=True` grabs the best release and never asks. A URL always uses it.
"""
if chosen.source == "youtube":
return _download_youtube(chosen, quality, root)
if chosen.kind == "album":
handled = mf.act_lidarr_album(chosen, root, False, False)
if handled:
return {"path": None, "lidarr_album_id": chosen.payload.get("album", {}).get("id")}
# No indexer release -> fall through to the top YouTube hit, like the CLI.
yt = next((h for h in hits if h.source == "youtube"), None)
if yt is None:
raise RuntimeError("No Lidarr release and no YouTube fallback available.")
return _download_youtube(yt, quality, root)
if chosen.kind == "artist":
# ensure_artist never adds a duplicate.
res = mf.ensure_artist(chosen.payload["artist"], root, False, False)
if res["state"] == "failed":
raise FetchError(f"Could not add {res['name']} to Lidarr.")
return {"outcome": f"artist_{res['state']}", "artist": res["name"],
"path": None, "lidarr_album_id": (res["artist"] or {}).get("id")}
# Lidarr artist pick.
ok = mf.act_lidarr_artist(chosen, root, False, False)
if not ok:
raise RuntimeError("Failed to add artist to Lidarr.")
return {"path": None, "lidarr_album_id": None}
res = mf.ensure_album(chosen.payload["album"], root, False, False)
artist, album = res["artist_name"], res["album_title"]
if res["album"] is None:
if res["artist_state"] == "failed":
raise FetchError(f"Could not add {artist} to Lidarr.")
raise FetchError(f"{artist} is in Lidarr, but it has no album called '{album}'.")
base = {"artist": artist, "album": album, "path": None,
"lidarr_album_id": res["album_id"],
"artist_state": res["artist_state"], "album_state": res["album_state"]}
releases = mf.list_releases(res["album_id"])
if not releases:
# Nothing on any indexer -> the top YouTube hit, like the CLI.
yt = next((h for h in hits if h.source == "youtube"), None) if allow_youtube else None
if yt is not None:
return {**base, **_download_youtube(yt, quality, root, outcome="youtube_fallback")}
return {**base, "outcome": "no_release"}
if auto:
return {**base, **_grab(releases[0], artist, album)}
releases = releases[:MAX_CHOICES]
return {**base, "outcome": "choices",
"choices": [_choice(i, r) for i, r in enumerate(releases, 1)],
"pending": releases}
def url_started_message(kind: str, title: str = "") -> str:
@@ -96,3 +166,70 @@ def perform_url_fetch(url: str, kind: str, title: str, hits: list, quality: str,
raise RuntimeError("Download failed.")
return {"kind": "track", "title": info.get("title", ""), "artist": info.get("artist", ""),
"ok": 1, "total": 1, "path": None, "lidarr_album_id": None}
# ---------------------------------------------------------------------------
# Lidarr find + choose (the two-step Siri Shortcuts flow)
# ---------------------------------------------------------------------------
def _choice(n: int, release: dict) -> dict:
"""The client-facing view of one indexer release."""
quality = ((release.get("quality") or {}).get("quality") or {}).get("name") or ""
size = release.get("size") or 0
label = mf.release_label(release)
return {
"n": n,
"label": label,
# "1. FLAC, 312 MB, ..." — a Siri Shortcut can show this in a list and
# read the number back off the item the user tapped.
"pick_label": f"{n}. {label}",
"title": release.get("title", ""),
"quality": quality,
"size_mb": round(float(size) / (1024 * 1024)) if size else None,
"seeders": release.get("seeders"),
"indexer": release.get("indexer", ""),
"rejected": bool(release.get("rejected")),
}
def choice_message(hit, result: dict) -> str:
"""Asked when the client must pick a release."""
artist = result.get("artist", "") or _primary_artist(hit)
album = result.get("album", "") or _title(hit)
lead = _artist_line(result.get("artist_state", ""), artist)
if result.get("album_state") == "monitored":
lead = f"{lead} Now monitoring '{album}'.".strip()
n = len(result.get("choices") or [])
ask = (f"I found {n} release{'s' if n != 1 else ''} for "
f"'{album}' by {artist}. Which one?")
return f"{lead} {ask}".strip()
def _grab(release: dict, artist: str, album: str) -> dict:
if not mf.grab_release(release, False):
raise FetchError(f"Lidarr refused the release for '{album}' by {artist}.")
return {"outcome": "album_grabbed", "release": mf.release_label(release),
"release_title": release.get("title", ""), "artist": artist, "album": album}
def grab_started_message(artist: str, album: str) -> str:
return f"Sending '{album}' by {artist} to the download client."
def grab_message(result: dict, artist: str = "", album: str = "") -> str:
"""Names the release when the result carries one. The artist/album
arguments are a fallback for a result that does not name them."""
who = result.get("artist") or artist
what = result.get("album") or album
base = f"Sent '{what}' by {who} to the download client."
label = result.get("release", "")
return f"{base} {label}." if label else base
def perform_grab(release: dict, artist: str, album: str) -> dict:
"""Push one exact release to the download client (used by POST /lidarr/choose)."""
return _grab(release, artist, album)
def fail_text(hit, exc: Exception) -> str:
"""A FetchError already reads like a sentence; anything else does not."""
return str(exc) if isinstance(exc, FetchError) and str(exc) else failed_message(hit)

View File

@@ -40,15 +40,29 @@ def _hit_public(hit) -> dict:
def _job_public(job) -> dict:
return {"message": job.message, "job_id": job.id, "status": job.status,
body = {"message": job.message, "job_id": job.id, "status": job.status,
"hit": _hit_public(job.hit) if job.hit is not None else None,
"result": job.result, "error": job.error}
# Only a parked job carries choices; keep the key out of every other body.
if job.choices:
body["choices"] = job.choices
return body
@app.post("/fetch", dependencies=[Depends(require_key)])
def fetch(q: str = Query(..., min_length=1),
quality: str = Query("best"),
source: str = Query("auto")):
source: str = Query("auto"),
auto: bool = Query(False),
limit: int = Query(10, ge=1, le=25)):
"""The single entry point.
A URL is grabbed directly — no prompt. Free text goes through Lidarr: the
artist and album land in the library, Lidarr runs an interactive search, and
the job parks at `awaiting_choice` with the releases. Pick one with
POST /choose. `auto=true` grabs the best release and never asks.
`source=youtube` skips Lidarr; `source=lidarr` drops the YouTube fallback.
"""
if quality not in mf.QUALITY_CHOICES:
raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.")
@@ -70,7 +84,7 @@ def fetch(q: str = Query(..., min_length=1),
# Non-direct link (Spotify/Apple/...): resolve via Odesli, then run the
# normal Lidarr-first pick/dispatch with the exact YouTube track fallback.
try:
query, hits = mf.resolve_link_hits(q, 10)
query, hits = mf.resolve_link_hits(q, limit)
except mf.OdesliError:
raise HTTPException(status_code=422,
detail=f"Couldn't resolve {q}. Try the direct YouTube or SoundCloud link.")
@@ -83,9 +97,10 @@ def fetch(q: str = Query(..., min_length=1),
response = _job_public(job)
jobs.run_job(
job.id,
lambda: actions.perform_fetch(chosen, hits, quality, ROOT),
done_message=actions.done_message(chosen),
fail_message=actions.failed_message(chosen),
# A URL is a direct grab, so never park it for a choice.
lambda: actions.perform_fetch(chosen, hits, quality, ROOT, auto=True),
done_message=lambda res: actions.done_message(chosen, res),
fail_message=lambda e: actions.fail_text(chosen, e),
)
return response
@@ -93,7 +108,7 @@ def fetch(q: str = Query(..., min_length=1),
raise HTTPException(status_code=422, detail=f"Invalid source '{source}'.")
yt_first = source == "youtube"
hits = mf.build_combined_hits(q, 10, yt_first,
hits = mf.build_combined_hits(q, limit, yt_first,
lidarr_only=(source == "lidarr"),
yt_only=(source == "youtube"))
if not hits:
@@ -105,11 +120,38 @@ def fetch(q: str = Query(..., min_length=1),
job = jobs.create_job(hit=chosen, message=actions.started_message(chosen))
response = _job_public(job) # snapshot "queued" state before background thread starts
jobs.run_choice_job(
job.id,
lambda: actions.perform_fetch(chosen, hits, quality, ROOT, auto=auto,
allow_youtube=(source != "lidarr")),
choice_message=lambda res: actions.choice_message(chosen, res),
done_message=lambda res: actions.done_message(chosen, res),
fail_message=lambda e: actions.fail_text(chosen, e),
)
return response
@app.post("/choose", dependencies=[Depends(require_key)])
def choose(job_id: str = Query(..., min_length=1), n: int = Query(..., ge=1)):
"""Send the release the client picked to the download client. `n` is the
number of a choice from the same job's `choices` list."""
try:
job, release = jobs.take_choice(job_id, n)
except KeyError:
raise HTTPException(status_code=404, detail="No such job.")
except jobs.ChoiceError as e:
raise HTTPException(status_code=409, detail=str(e))
prev = job.result or {}
artist, album = prev.get("artist", ""), prev.get("album", "")
jobs.requeue(job, actions.grab_started_message(artist, album))
response = _job_public(job)
jobs.run_job(
job.id,
lambda: actions.perform_fetch(chosen, hits, quality, ROOT),
done_message=actions.done_message(chosen),
fail_message=actions.failed_message(chosen),
lambda: actions.perform_grab(release, artist, album),
done_message=actions.grab_message,
fail_message=lambda e: (str(e) if isinstance(e, actions.FetchError) and str(e)
else "Lidarr refused that release."),
)
return response

View File

@@ -1,5 +1,10 @@
"""In-memory async job store. Personal-scale: jobs are lost on restart.
Generic — knows nothing about musicfetch; callers pass a no-arg `fn`."""
Generic — knows nothing about musicfetch; callers pass a no-arg `fn`.
A job can also park at `awaiting_choice`: the worker produced a list of
`choices` and waits for the client to pick one (`take_choice`). A parked job
expires after CHOICE_TTL seconds so the store never fills with dead prompts."""
import os
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
@@ -9,16 +14,25 @@ from typing import Any, Callable, Optional
_EXECUTOR = ThreadPoolExecutor(max_workers=2)
JOBS: "dict[str, Job]" = {}
_MAX_JOBS = 200 # cap to bound memory
CHOICE_TTL = float(os.environ.get("MUSICFETCH_CHOICE_TTL", "900")) # 15 minutes
class ChoiceError(Exception):
"""The client's pick cannot be used. The text is safe to show a user."""
@dataclass
class Job:
id: str
status: str # queued | running | done | failed
# queued | running | awaiting_choice | done | failed | expired
status: str
hit: Any
message: str
result: Optional[dict] = None
error: Optional[str] = None
choices: Optional[list] = None # [{"n": 1, "label": "..."}] shown to the client
pending: Optional[list] = None # server-side data behind each choice
expires_at: Optional[float] = None
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
@@ -45,12 +59,26 @@ def create_job(hit: Any, message: str) -> "Job":
def get_job(job_id: str) -> Optional["Job"]:
return JOBS.get(job_id)
job = JOBS.get(job_id)
if job is not None and _is_stale(job):
_touch(job, status="expired", choices=None, pending=None,
message="That choice list has expired. Please search again.")
return job
def _is_stale(job: "Job") -> bool:
return (job.status == "awaiting_choice"
and job.expires_at is not None
and time.time() > job.expires_at)
def _fail_text(fail_message, exc: Exception) -> str:
return fail_message(exc) if callable(fail_message) else fail_message
def run_job(job_id: str, fn: Callable[[], dict],
done_message: "str | Callable[[dict], str]",
fail_message: str = "Something went wrong while fetching.") -> None:
fail_message: "str | Callable[[Exception], str]" = "Something went wrong while fetching.") -> None:
def _task():
job = JOBS.get(job_id)
if job is None:
@@ -62,5 +90,69 @@ def run_job(job_id: str, fn: Callable[[], dict],
_touch(job, status="done", result=result, message=msg)
except Exception as e: # noqa: BLE001 — record any failure on the job
_touch(job, status="failed", error=f"{type(e).__name__}: {e}",
message=fail_message)
message=_fail_text(fail_message, e))
_EXECUTOR.submit(_task)
def await_choice(job_id: str, choices: list, pending: list, message: str) -> None:
"""Park a running job until the client picks one of `choices`."""
job = JOBS.get(job_id)
if job is None:
return
_touch(job, status="awaiting_choice", choices=choices, pending=pending,
message=message, expires_at=time.time() + CHOICE_TTL)
def requeue(job: "Job", message: str) -> None:
"""Re-arm a parked job for a second piece of work (the client's pick)."""
_touch(job, status="queued", message=message, error=None)
def take_choice(job_id: str, n: int) -> tuple["Job", Any]:
"""Return (job, the picked pending item) and clear the prompt.
Raises ChoiceError with a user-safe reason when the pick cannot be used."""
job = get_job(job_id)
if job is None:
raise KeyError(job_id)
if job.status == "expired":
raise ChoiceError("That choice list has expired. Please search again.")
if job.status != "awaiting_choice":
raise ChoiceError(f"That search is not waiting for a choice (it is {job.status}).")
pending = job.pending or []
if not isinstance(n, int) or n < 1 or n > len(pending):
raise ChoiceError(f"Pick a number between 1 and {len(pending)}.")
item = pending[n - 1]
# Leave awaiting_choice in the same touch that clears the list, so a second
# request cannot take the same choice.
_touch(job, status="queued", choices=None, pending=None, expires_at=None)
return job, item
def run_choice_job(job_id: str, fn: Callable[[], dict],
choice_message: Callable[[dict], str],
done_message: "str | Callable[[dict], str]",
fail_message: "str | Callable[[Exception], str]" = "Something went wrong while searching.") -> None:
"""Like run_job, but a result carrying a non-empty "choices" list parks the
job at awaiting_choice instead of finishing it."""
def _task():
job = JOBS.get(job_id)
if job is None:
return
_touch(job, status="running")
try:
result = fn()
# `pending` holds indexer guids and `choices` is already top-level,
# so neither belongs in the public `result` body.
public = {k: v for k, v in result.items() if k not in ("pending", "choices")}
choices = result.get("choices") or []
if choices:
await_choice(job_id, choices, result.get("pending") or [],
choice_message(result))
_touch(job, result=public)
return
msg = done_message(result) if callable(done_message) else done_message
_touch(job, status="done", result=public, message=msg)
except Exception as e: # noqa: BLE001 — record any failure on the job
_touch(job, status="failed", error=f"{type(e).__name__}: {e}",
message=_fail_text(fail_message, e))
_EXECUTOR.submit(_task)

View File

@@ -19,6 +19,14 @@ sys.modules["musicfetch_core"] = _mod
Hit = _mod.Hit
build_combined_hits = _mod.build_combined_hits
lidarr_search = _mod.lidarr_search
find_library_artist = _mod.find_library_artist
ensure_artist = _mod.ensure_artist
ensure_album = _mod.ensure_album
list_releases = _mod.list_releases
sort_releases = _mod.sort_releases
release_label = _mod.release_label
grab_release = _mod.grab_release
pick = _mod.pick
act_youtube = _mod.act_youtube
act_lidarr_album = _mod.act_lidarr_album
@@ -34,7 +42,9 @@ resolve_link_hits = _mod.resolve_link_hits
handle_link = _mod.handle_link
OdesliError = _mod.OdesliError
__all__ = ["Hit", "build_combined_hits", "pick", "act_youtube",
__all__ = ["Hit", "build_combined_hits", "lidarr_search", "find_library_artist",
"ensure_artist", "ensure_album", "list_releases", "sort_releases",
"release_label", "grab_release", "pick", "act_youtube",
"act_lidarr_album", "act_lidarr_artist", "QUALITY_CHOICES",
"is_url", "probe_url", "download_hits", "download_single",
"_is_direct_url", "odesli_resolve", "resolve_link_hits",