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

@@ -3,5 +3,6 @@ __pycache__/
tests/
docs/
.git/
.github/
*.md
.claude/

63
.gitattributes vendored Normal file
View File

@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

93
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: Docker Build
on:
push:
branches:
- master
workflow_dispatch:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
statuses: write
env:
IMAGE_REGISTRY: gitea.theannex.dev/izebra_projects/musicfetch
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.GITEA_USERNAME }}
password: ${{ secrets.GITEA_TOKEN }}
registry: gitea.theannex.dev
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_REGISTRY }}
tags: |
type=raw,value=latest
type=sha
type=sha,format=short
type=ref,event=branch
labels: |
org.opencontainers.image.source=${{ github.repositoryUrl }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
org.opencontainers.image.title=musicfetch
org.opencontainers.image.description=Automated build from commit ${{ github.sha }}
- name: Build and push Docker image
id: build_push
uses: docker/build-push-action@v5
with:
context: .
file: server/Dockerfile
push: true
provenance: false
sbom: false
cache-from: type=registry,ref=${{ env.IMAGE_REGISTRY }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_REGISTRY }}:buildcache,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Set commit status (image pushed)
uses: actions/github-script@v7
with:
script: |
const sha = context.sha;
const tags = `${process.env.IMAGE_REGISTRY}:latest`;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha,
state: 'success',
context: 'docker-image',
description: `Image pushed with tags: latest, sha`,
target_url: `https://${process.env.IMAGE_REGISTRY}`
});
- name: Post build summary
run: |
echo "### Docker Image Build" >> $GITHUB_STEP_SUMMARY
echo "Registry: $IMAGE_REGISTRY" >> $GITHUB_STEP_SUMMARY
echo "Commit: $GITHUB_SHA" >> $GITHUB_STEP_SUMMARY
echo "Tags applied:" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY

132
README.md
View File

@@ -98,6 +98,7 @@ export LIDARR_API_KEY="your-lidarr-api-key"
| `--yt-only` | Skip Lidarr. |
| `-o`, `--root PATH` | Output root folder (default `/media/music`). |
| `--search-all` | Search all albums when adding an artist to Lidarr. |
| `--pick-release` | Show Lidarr's indexer releases and choose one, instead of letting Lidarr grab the best automatically. |
| `--repair` | Re-tag existing downloads under `--root` from source metadata (see below). |
| `--workers N` | Parallel metadata fetches during `--repair` (default 4). |
| `--cookies FILE` | yt-dlp `cookies.txt` for authenticated YouTube (avoids bot-check / rate limits). |
@@ -300,25 +301,83 @@ serves plain HTTP on `6769`.
| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| `GET` | `/health` | no | Liveness check. |
| `POST` | `/fetch?q=...` | yes | Grab top hit; returns a `job_id`. |
| `POST` | `/fetch?q=...` | yes | The one entry point. Returns a `job_id`. |
| `POST` | `/choose?job_id=...&n=...` | yes | Send the chosen release to the download client. |
| `GET` | `/jobs/{id}` | yes | Poll job status. |
`POST /fetch` decides what to do from `q`:
- **`q` is a URL** → grab it directly. No prompt. A direct YouTube or SoundCloud
link downloads at once; a Spotify or Apple Music link resolves through Odesli
first.
- **`q` is free text** → go through Lidarr. The artist and the 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`.
`POST /fetch` params: `q` (required), `quality` (`best,320,m4a,opus,flac`),
`source` (`auto,lidarr,youtube`).
`source` (`auto,lidarr,youtube`), `auto` (`false` by default — `true` grabs the
best release and never asks), `limit` (1–25, default 10).
- `source=youtube` skips Lidarr. The top YouTube hit downloads at once.
- `source=lidarr` drops the YouTube fallback. No release means `no_release`.
- `auto=true` never parks the job. A URL always behaves this way.
`POST /choose` params: `job_id` and `n` (both required). `n` is the number of a
choice from the same job.
Job `status` is one of `queued`, `running`, `awaiting_choice`, `done`, `failed`
or `expired`. A job at `awaiting_choice` carries a `choices` array and waits for
`POST /choose`. It expires after 15 minutes (`MUSICFETCH_CHOICE_TTL`).
Every `message` states what really happened. An artist add says so — it does not
claim a download. A Lidarr grab says the release went to the download client.
### curl examples
```bash
# Kick off a fetch
curl -X POST 'https://mf.izebra.net/fetch?q=Under%20My%20Skin' \
# A URL: grabbed directly, no prompt.
curl -X POST 'https://mf.izebra.net/fetch?q=https://youtu.be/abc123' \
-H 'X-API-Key: a-long-random-secret'
# -> {"message":"Found 'Under My Skin' ... Downloading now.","job_id":"a1b2c3","status":"queued","hit":{...}}
# -> {"message":"Fetching '...'. Downloading now.","job_id":"a1b2c3","status":"queued"}
# Poll the job
# Poll any job.
curl 'https://mf.izebra.net/jobs/a1b2c3' -H 'X-API-Key: a-long-random-secret'
# -> {"message":"Finished downloading ...","status":"done","result":{...}}
# -> {"message":"Downloaded '...'.","status":"done","result":{...}}
# Text, and take whatever Lidarr thinks is best.
curl -X POST 'https://mf.izebra.net/fetch?auto=true&q=Overgrown%20-%20Lay%20Cold' \
-H 'X-API-Key: a-long-random-secret'
```
### Choose the release yourself
```bash
# 1. Free text. Lidarr adds the artist only if it is missing, monitors the
# album, then runs an interactive search. That takes up to a minute, so the
# search is part of the job.
curl -X POST 'https://mf.izebra.net/fetch?q=Overgrown%20-%20Lay%20Cold' \
-H 'X-API-Key: a-long-random-secret'
# -> {"message":"Found the album 'Lay Cold' by Overgrown in Lidarr. Searching indexers now.",
# "job_id":"a1b2c3","status":"queued","hit":{...}}
# 2. Poll until the job asks you a question.
curl 'https://mf.izebra.net/jobs/a1b2c3' -H 'X-API-Key: a-long-random-secret'
# -> {"status":"awaiting_choice",
# "message":"Overgrown is already in Lidarr. I found 3 releases for 'Lay Cold' by Overgrown. Which one?",
# "choices":[{"n":1,"label":"FLAC, 312 MB, 24 seeders, Redacted",
# "pick_label":"1. FLAC, 312 MB, 24 seeders, Redacted",
# "quality":"FLAC","size_mb":312,
# "seeders":24,"indexer":"Redacted","rejected":false}, ...]}
# 3. Pick one. Lidarr sends that exact release to the download client.
curl -X POST 'https://mf.izebra.net/choose?job_id=a1b2c3&n=1' \
-H 'X-API-Key: a-long-random-secret'
# -> {"message":"Sending 'Lay Cold' by Overgrown to the download client.","status":"queued"}
```
A job never parks when no indexer has the album. It falls back to YouTube Music
instead, and says so.
### 🗣️ Siri Shortcuts integration
Make a shortcut that fetches music by voice ("Hey Siri, fetch music").
@@ -326,9 +385,10 @@ Make a shortcut that fetches music by voice ("Hey Siri, fetch music").
1. **Shortcuts app → New Shortcut.**
2. Add **Ask for Input** → Input Type **Text**, prompt "What should I fetch?".
(Or use **Dictate Text** for fully spoken input.)
3. Add **Text** action, set it to: `https://mf.izebra.net/fetch?q=` then insert
the **Provided Input** variable at the end. (Shortcuts URL-encodes query
variables automatically.)
3. Add **Text** action, set it to: `https://mf.izebra.net/fetch?auto=true&q=`
then insert the **Provided Input** variable at the end. (Shortcuts
URL-encodes query variables automatically.) `auto=true` keeps this shortcut
one-shot — without it, free text stops and asks which release you want.
4. Add **Get Contents of URL**:
- **URL:** the Text variable from step 3.
- **Method:** `POST`.
@@ -344,6 +404,58 @@ Make a shortcut that fetches music by voice ("Hey Siri, fetch music").
Rename the shortcut (e.g. "Fetch Music") — that phrase becomes the Siri trigger.
#### Shortcut 2: choose the release yourself
This shortcut lists the indexer releases and lets you pick one.
Shortcuts has no "exit loop" action and no **OR** in an **If**, so the poll uses
a `Keep` flag and one **is** test per status.
**Start the search**
1. **Ask for Input** → Text, prompt "What should I find?".
2. **Text**: `https://mf.izebra.net/fetch?q=` + the **Provided Input**.
3. **Get Contents of URL** → Method `POST`, header `X-API-Key`.
4. **Get Dictionary Value** `job_id` → **Set Variable** `JobID`.
5. **Set Variable** `Keep` to `1`.
**Wait for the question** (the interactive search takes up to 90 seconds)
6. **Repeat** 20 times, with this inside:
- **If** `Keep` **is** `1`:
- **Text**: `https://mf.izebra.net/jobs/` + `JobID`.
- **Get Contents of URL** → Method `GET`, header `X-API-Key`.
- **Set Variable** `Reply` to **Contents of URL**.
- **Get Dictionary Value** `status` from `Reply` → **Set Variable** `Status`.
- **Set Variable** `Keep` to `0`.
- **If** `Status` **is** `queued` → **Set Variable** `Keep` to `1`.
- **If** `Status` **is** `running` → **Set Variable** `Keep` to `1`.
- **If** `Keep` **is** `1` → **Wait** 5 seconds.
**Read the answer**
7. **Get Dictionary Value** `message` from `Reply` → **Speak Text**.
8. **If** `Status` **is not** `awaiting_choice` → **Stop Shortcut**. (The job
finished or failed on its own. Step 7 already said why.)
**Pick a release**
9. **Get Dictionary Value** `choices` from `Reply`.
10. **Repeat with Each** item in it:
- **Get Dictionary Value** `pick_label` → **Add to Variable** `Labels`.
11. **Choose from List** `Labels`. Each row reads
`1. FLAC, 312 MB, 24 seeders, Redacted`.
12. **Split Text** the chosen row by Custom separator `. ` → **Get Item 1 from
List**. That is the number `n`.
13. **Text**: `https://mf.izebra.net/choose?job_id=` + `JobID` + `&n=` +
the number from step 12.
14. **Get Contents of URL** → Method `POST`, header `X-API-Key`.
15. **Get Dictionary Value** `message` → **Speak Text**
("Sending 'Lay Cold' … to the download client.").
The choice list stays valid for 15 minutes. After that, `/choose` answers `409`
and the job reads `expired`.
---
## 🛠️ Contributing

View File

@@ -0,0 +1,126 @@
# Lidarr Rework — Design
**Date:** 2026-09-18
**Status:** Implemented
## Context & Goal
The Lidarr side of MusicFetch said things that were not true, and it always
tried to add an artist that the library already held. It also let Lidarr decide
which indexer release to grab. This rework makes the Lidarr path honest,
library-aware, and able to ask the user which release to take.
## Bugs this fixes
1. **Wrong message.** `done_message` used the hit's display title. An artist hit
puts the artist name in both slots, so the API said
`Finished downloading 'Michael Jackson' by Michael Jackson.` An artist add
also downloads nothing, so "Finished downloading" was wrong twice.
2. **Always a duplicate artist.** `act_lidarr_artist` posted `/api/v1/artist`
without a library check. `get_existing_artist` existed but only ran inside
`ensure_album_in_library`. Lidarr then rejected the duplicate and the job
failed.
3. **The library was invisible to the search.** A bare name went to
`/api/v1/artist/lookup`, which searches MusicBrainz, not the local library.
4. **Silent wrong album.** `ensure_album_in_library` returned `albums[0]` when no
title matched, so MusicFetch could grab an unrelated album.
5. **Race after an artist add.** Lidarr fills an artist's album list after the
POST returns. The old code read the list at once, found nothing, and hit the
`albums[0]` path above.
6. **A double search.** The code ran `/api/v1/release` (Lidarr's interactive
search, up to 90 s), counted the results, threw the list away, then ran the
`AlbumSearch` command, which searched again and picked on its own.
## Decisions
- **musicfetch stays a standalone single-file script.** All new Lidarr logic
lives in it. The API imports it, as before.
- **Match an artist by MusicBrainz id first, then by a normalised name.**
`find_library_artist` reads `/api/v1/artist` (the library), not the metadata
lookup. `ensure_artist` returns a state: `existing`, `added` or `failed`.
- **Match an album by MusicBrainz id, then exact title, then title without an
edition suffix. Never by position.** No match returns `None`.
- **Poll for the album list after an artist add** (up to 40 s, every 2 s).
- **Monitor the album before a grab.** Lidarr refuses a grab for an unmonitored
album.
- **Grab one exact release.** `POST /api/v1/release {guid, indexerId}` replaces
the `AlbumSearch` command, so the user's pick is the release that downloads.
The `AlbumSearch` helper is gone — it had no caller left.
- **Every message states the real outcome**: `artist_added`, `artist_existing`,
`album_grabbed`, `youtube_fallback`, `no_release`.
## One endpoint
`POST /fetch` is the only entry point. It decides from `q`:
- **A URL is a direct grab.** No prompt. `auto=True` is forced.
- **Free text goes through Lidarr**, then offers the releases.
`source` still narrows it: `youtube` skips Lidarr, `lidarr` drops the YouTube
fallback.
A separate `/lidarr/find` was rejected. Two endpoints that both mean "get me
this" is a choice the caller should not have to make, and a Shortcut would have
to branch on whether the input looks like a URL. `/fetch` already knew how to
tell them apart.
## The two-step choose flow
Lidarr's interactive search takes 10–90 s. A Siri Shortcut cannot hold an HTTP
request open that long, so the search is part of the job and the client polls it.
```
POST /fetch?q=Overgrown - Lay Cold
-> {"job_id": "a1b2c3", "status": "queued", "hit": {...}} (returns at once)
worker: ensure_artist -> ensure_album -> monitor
-> list_releases (interactive search)
GET /jobs/a1b2c3
-> {"status": "awaiting_choice",
"message": "Overgrown is already in Lidarr. I found 3 releases ... Which one?",
"choices": [{"n": 1, "label": "FLAC, 312 MB, 24 seeders, Redacted",
"pick_label": "1. FLAC, 312 MB, ...", ...}]}
POST /choose?job_id=a1b2c3&n=1
-> grab_release(guid, indexerId) -> the download client
```
The hit resolution stays in the request, so a query that finds nothing is still
a `404` and the response still carries the `hit` for Siri to read back. Only the
slow part — ensure, monitor, interactive search — moves into the job.
A job parks at `awaiting_choice` and holds the raw releases server-side. The
client only ever sees `n`, a label and a numbered `pick_label`, so no `guid`
leaves the server. A parked job expires after `MUSICFETCH_CHOICE_TTL` seconds
(default 900). `?auto=true` grabs the best release and skips the prompt.
No indexer release means the job never parks. It falls back to the top YouTube
hit, as the CLI does, unless `source=lidarr`.
`MUSICFETCH_MAX_CHOICES` (default 10) caps the list, because a spoken list must
stay short. Order is: usable releases first, then most seeders, then largest
file.
## Failure text
`actions.FetchError` marks a failure whose text is written for a person to hear
("Overgrown is in Lidarr, but it has no album called 'Lay Cold'."). The job
message uses it. Any other exception falls back to the generic
`failed_message`, because a yt-dlp stack trace is not something Siri should read
out.
## CLI parity
`musicfetch --pick-release` shows the same releases in a table and asks which
one. It shares `list_releases`, `release_label` and `grab_release` with the API,
so the two paths cannot drift.
## Rejected alternatives
- **Keep `AlbumSearch` after the interactive search.** It ignores the user's
pick and searches twice.
- **A synchronous search endpoint.** The interactive search is too slow for a
Shortcut's HTTP action.
- **A separate store for pending choices.** The job store already has the right
shape, a TTL and an eviction cap.

View File

@@ -167,6 +167,12 @@ def lidarr_post(path, payload, timeout=15):
return resp.json() if resp.content else {}
def lidarr_put(path, payload, timeout=15):
resp = requests.put(f"{LIDARR_URL}{path}", headers=HEADERS, json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json() if resp.content else {}
# ---------------------------------------------------------------------------
# Lidarr search
# ---------------------------------------------------------------------------
@@ -516,16 +522,47 @@ def pick(hits: list[Hit], query: str, noninteractive: bool, yt_first: bool) -> O
# ---------------------------------------------------------------------------
# Lidarr actions
# ---------------------------------------------------------------------------
def get_existing_artist(name: str) -> Optional[dict]:
def _norm_title(s: str) -> str:
"""Casefold + drop punctuation so 'Off The Wall!' == 'off the wall'.
Keeps letters from any alphabet, so 'Björk' does not become 'bj rk'."""
return re.sub(r"[^\w]+", " ", (s or "").casefold(), flags=re.UNICODE).strip()
def _base_title(s: str) -> str:
"""_norm_title without a trailing edition suffix: 'Thriller (Deluxe)' -> 'thriller'."""
return _norm_title(re.sub(r"[\(\[][^\)\]]*[\)\]]\s*$", "", s or ""))
def library_artists() -> list[dict]:
"""Every artist already in the Lidarr library. [] when Lidarr is unreachable."""
try:
for artist in lidarr_get("/api/v1/artist", timeout=10):
if artist.get("artistName", "").lower() == name.lower():
return artist
return lidarr_get("/api/v1/artist", timeout=10)
except RequestException as e:
dbg(f"existing artist check failed: {e}")
_log_lidarr_failure("artist list", e)
return []
def find_library_artist(name: str = "", foreign_id: str = "") -> Optional[dict]:
"""Find an artist already in the library. Matches the MusicBrainz id first
(exact), then the name (normalised). Returns None when it is not there."""
artists = library_artists()
if foreign_id:
for a in artists:
if (a.get("foreignArtistId") or "") == foreign_id:
return a
target = _norm_title(name)
if target:
for a in artists:
if _norm_title(a.get("artistName", "")) == target:
return a
return None
def get_existing_artist(name: str) -> Optional[dict]:
"""Backwards-compatible name lookup."""
return find_library_artist(name=name)
def _profile_id_by_name(path: str, env_var: str, default_name: str) -> int:
"""Return the id of the profile whose name matches env_var (default
default_name, case-insensitive). Fall back to the first profile, then 1."""
@@ -577,87 +614,311 @@ def add_artist(meta: dict, root: str, search_all: bool, dry_run: bool) -> Option
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
def ensure_artist(meta: dict, root: str, search_all: bool, dry_run: bool) -> dict:
"""Make sure the artist is in the Lidarr library. Never adds a duplicate.
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
Returns {"artist": dict|None, "state": "existing"|"added"|"failed", "name": str}.
"""
name = meta.get("artistName") or meta.get("title") or ""
existing = find_library_artist(name=name, foreign_id=meta.get("foreignArtistId") or "")
if existing:
dbg(f"artist '{name}' already in library (id={existing.get('id')})")
return {"artist": existing, "state": "existing",
"name": existing.get("artistName") or name}
added = add_artist(meta, root, search_all, dry_run)
if not added:
return {"artist": None, "state": "failed", "name": name}
return {"artist": added, "state": "added",
"name": added.get("artistName") or name}
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)
def _match_album(albums: list[dict], target: dict) -> Optional[dict]:
"""Pick the library album that is the target. MusicBrainz id first, then the
exact title, then the title without an edition suffix. None when no match —
never a random album."""
fid = (target.get("foreignAlbumId") or "").casefold()
if fid:
for a in albums:
if a.get("title", "").lower() == album.get("title", "").lower():
if (a.get("foreignAlbumId") or "").casefold() == fid:
return a
want = _norm_title(target.get("title", ""))
if not want:
return None
for a in albums:
if _norm_title(a.get("title", "")) == want:
return a
base = _base_title(target.get("title", ""))
if base:
for a in albums:
if _base_title(a.get("title", "")) == base:
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?"""
def _artist_albums(artist_id: int) -> list[dict]:
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
return lidarr_get("/api/v1/album", params={"artistId": artist_id}, timeout=15)
except RequestException as e:
dbg(f"album list fetch failed: {e}")
return []
def _await_album(artist_id: int, target: dict, timeout: int = 40) -> Optional[dict]:
"""Lidarr fills an artist's album list after the add returns, so poll for it."""
deadline = time.time() + timeout
while True:
match = _match_album(_artist_albums(artist_id), target)
if match or time.time() >= deadline:
return match
time.sleep(2.0)
def set_album_monitored(album: dict, dry_run: bool) -> bool:
"""Monitor the album so Lidarr accepts a grab for it. True when monitored."""
if album.get("monitored"):
return True
album_id = album.get("id")
if not isinstance(album_id, int) or album_id <= 0:
err(f"Album '{album.get('title')}' has no library id; cannot monitor it.")
return False
if dry_run:
print(f"[dry-run] PUT /api/v1/album/monitor albumIds=[{album_id}] monitored=True")
return True
try:
# The dedicated endpoint — a full album PUT can overwrite other fields.
lidarr_put("/api/v1/album/monitor",
{"albumIds": [album_id], "monitored": True})
return True
except RequestException as e:
err(f"could not monitor album '{album.get('title')}': {e}")
return False
def ensure_album(album: dict, root: str, search_all: bool, dry_run: bool) -> dict:
"""Make sure the album (and its artist) are in the library and monitored.
Returns {"album", "album_id", "artist_name", "album_title",
"artist_state": existing|added|failed,
"album_state": existing|monitored|missing}.
"""
artist_obj = album.get("artist") or {}
artist_name = artist_obj.get("artistName") or album.get("artistName") or ""
meta = dict(artist_obj) or {}
meta.setdefault("artistName", artist_name)
if album.get("foreignArtistId"):
meta.setdefault("foreignArtistId", album["foreignArtistId"])
out = {"album": None, "album_id": None, "artist_name": artist_name,
"album_title": album.get("title", ""), "artist_state": "failed",
"album_state": "missing"}
res = ensure_artist(meta, root, search_all, dry_run)
out["artist_state"] = res["state"]
out["artist_name"] = res["name"] or artist_name
if res["state"] == "failed":
return out
artist_id = (res["artist"] or {}).get("id")
if not dry_run and (not isinstance(artist_id, int) or artist_id <= 0):
err(f"Lidarr gave no library id for {out['artist_name']}.")
return out
if dry_run:
out.update(album={**album, "id": album.get("id") or -1, "artistId": artist_id},
album_id=album.get("id") or -1, album_state="existing")
return out
lib_album = _match_album(_artist_albums(artist_id), album)
if lib_album is None and res["state"] == "added":
lib_album = _await_album(artist_id, album)
if lib_album is None:
return out
was_monitored = bool(lib_album.get("monitored"))
if not set_album_monitored(lib_album, dry_run):
return out
out.update(album=lib_album, album_id=lib_album.get("id"),
album_title=lib_album.get("title") or out["album_title"],
album_state="existing" if was_monitored else "monitored")
return out
def ensure_album_in_library(album: dict, root: str, search_all: bool, dry_run: bool) -> Optional[dict]:
"""Backwards-compatible wrapper: the library album dict, or None."""
return ensure_album(album, root, search_all, dry_run)["album"]
# --- releases (Lidarr "interactive search") --------------------------------
def list_releases(album_id: int, timeout: int = 90) -> list[dict]:
"""Run Lidarr's interactive search. Returns the raw release list, best first."""
if not isinstance(album_id, int) or album_id <= 0:
return []
try:
releases = lidarr_get("/api/v1/release", params={"albumId": album_id}, timeout=timeout)
except RequestException as e:
dbg(f"release search failed: {e}")
return []
dbg(f"interactive search returned {len(releases)} releases for album {album_id}")
return sort_releases(releases)
def sort_releases(releases: list[dict]) -> list[dict]:
"""Usable releases first, then the most seeders, then the largest file."""
def key(r):
return (bool(r.get("rejected")), -int(r.get("seeders") or 0), -int(r.get("size") or 0))
return sorted(releases, key=key)
def release_available(album_id: int) -> bool:
"""True when at least one indexer has a release for this album."""
return bool(list_releases(album_id))
def _human_size(size) -> str:
try:
mb = float(size or 0) / (1024 * 1024)
except (TypeError, ValueError):
return ""
if mb <= 0:
return ""
return f"{mb / 1024:.1f} GB" if mb >= 1024 else f"{mb:.0f} MB"
def release_label(release: dict) -> str:
"""One short line a person (or Siri) can understand."""
parts = []
quality = ((release.get("quality") or {}).get("quality") or {}).get("name")
if quality:
parts.append(str(quality))
size = _human_size(release.get("size"))
if size:
parts.append(size)
seeders = release.get("seeders")
if seeders is not None:
parts.append(f"{int(seeders)} seeders")
indexer = release.get("indexer")
if indexer:
parts.append(str(indexer))
label = ", ".join(parts) or (release.get("title") or "unknown release")
if release.get("rejected"):
reasons = release.get("rejections") or []
why = str(reasons[0]) if reasons else "rejected by Lidarr"
label = f"{label} (not recommended: {why})"
return label
def grab_release(release: dict, dry_run: bool = False) -> bool:
"""Send one exact release to the download client. True on success."""
guid, indexer_id = release.get("guid"), release.get("indexerId")
if not guid or indexer_id is None:
err("Release is missing guid/indexerId; cannot grab it.")
return False
payload = {"guid": guid, "indexerId": indexer_id}
if dry_run:
print(f"[dry-run] POST /api/v1/release {json.dumps(payload)}")
return True
try:
lidarr_post("/api/v1/release", payload, timeout=60)
return True
except RequestException as e:
err(f"grab 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 render_releases(releases: list[dict]) -> None:
if _console is not None:
table = Table(show_lines=False, expand=False)
table.add_column("#", justify="right", style="cyan")
table.add_column("Quality")
table.add_column("Size", justify="right")
table.add_column("Seeders", justify="right")
table.add_column("Indexer")
table.add_column("Release")
for i, r in enumerate(releases, 1):
quality = ((r.get("quality") or {}).get("quality") or {}).get("name") or ""
style = "red" if r.get("rejected") else ""
table.add_row(str(i), quality, _human_size(r.get("size")),
str(r.get("seeders") if r.get("seeders") is not None else ""),
str(r.get("indexer") or ""),
Text(r.get("title") or "", style=style))
_console.print(table)
else:
for i, r in enumerate(releases, 1):
print(f"{i:>3} {release_label(r)}")
print(f" {r.get('title', '')}")
def act_lidarr_album(hit: Hit, root: str, search_all: bool, dry_run: bool) -> bool:
def pick_release(releases: list[dict]) -> Optional[dict]:
"""Show the interactive-search results and let the user choose one."""
if not releases:
return None
render_releases(releases)
while True:
try:
raw = input("Pick a release (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(releases):
return releases[int(raw) - 1]
print("Invalid choice.")
# --- dispatch --------------------------------------------------------------
def act_lidarr_album(hit: Hit, root: str, search_all: bool, dry_run: bool,
choose_release: bool = False) -> 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.")
res = ensure_album(album, root, search_all, dry_run)
if res["album"] is None:
err(f"Could not resolve '{hit.album}' in Lidarr "
f"(artist {res['artist_state']}, album {res['album_state']}).")
return False
album_id = lib_album.get("id")
album_id = res["album_id"]
label = f"'{res['album_title']}' by {res['artist_name']}"
if res["artist_state"] == "added":
print(f"Added {res['artist_name']} to Lidarr.")
else:
print(f"{res['artist_name']} is already in Lidarr.")
if res["album_state"] == "monitored":
print(f"Now monitoring {label}.")
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)
print(f"[dry-run] would interactive-search album id {album_id} and grab the best release.")
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)
releases = list_releases(album_id)
if not releases:
print(f"No indexer has a release for {label} — falling through to YouTube.")
return False
if choose_release:
chosen = pick_release(releases)
if chosen is None:
print("No release picked — nothing sent to the download client.")
return True
else:
chosen = releases[0]
if grab_release(chosen, dry_run):
print(f"Sent {label} to the download client ({release_label(chosen)}).")
return True
print("No indexer release found in Lidarr — falling through to YouTube.")
err(f"Lidarr refused the release for {label}.")
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
"""Add the artist only when the library does not have them already."""
res = ensure_artist(hit.payload["artist"], root, search_all, dry_run)
if res["state"] == "existing":
print(f"{res['name']} is already in Lidarr — nothing added.")
return True
if res["state"] == "added":
print(f"Added {res['name']} to Lidarr and monitoring their albums.")
return True
err(f"Could not add {res['name']} to Lidarr.")
return False
# ---------------------------------------------------------------------------
@@ -1339,6 +1600,9 @@ def parse_args():
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("--pick-release", action="store_true",
help="Show Lidarr's indexer releases and choose one, instead of "
"letting Lidarr grab the best automatically.")
p.add_argument("--repair", action="store_true",
help="Re-tag existing downloads under --root from source metadata.")
p.add_argument("--workers", type=int, default=4,
@@ -1369,12 +1633,14 @@ def parse_args():
def _dispatch_chosen(chosen: Hit, hits: list[Hit], root: str, quality: str,
dry_run: bool, lidarr_only: bool, search_all: bool) -> None:
"""Act on a picked Hit: Lidarr album (add+search, fall to top YouTube hit on
dry_run: bool, lidarr_only: bool, search_all: bool,
choose_release: bool = False) -> None:
"""Act on a picked Hit: Lidarr album (add+grab, fall to top YouTube hit on
no release), Lidarr artist, or a YouTube track. Shared by main() and handle_link."""
if chosen.source == "lidarr":
if chosen.kind == "album":
handled = act_lidarr_album(chosen, root, search_all, dry_run)
handled = act_lidarr_album(chosen, root, search_all, dry_run,
choose_release=choose_release)
if not handled and not lidarr_only:
yt_fallback = next((h for h in hits if h.source == "youtube"), None)
if yt_fallback:
@@ -1438,7 +1704,8 @@ def main():
return
_dispatch_chosen(chosen, hits, args.root, args.quality, args.dry_run,
args.lidarr_only, args.search_all)
args.lidarr_only, args.search_all,
choose_release=args.pick_release and not args.noninteractive)
if __name__ == "__main__":

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",

View File

@@ -50,18 +50,47 @@ def test_perform_youtube_calls_act_youtube(monkeypatch):
assert result["lidarr_album_id"] is None
def test_perform_lidarr_album_handled(monkeypatch):
monkeypatch.setattr(mf, "act_lidarr_album",
lambda hit, root, search_all, dry_run: True)
def _release(guid="g1"):
return {"guid": guid, "indexerId": 2, "title": "Avril-Under_My_Skin",
"size": 312 * 1024 * 1024, "seeders": 24, "indexer": "Redacted",
"quality": {"quality": {"name": "FLAC"}}}
@pytest.fixture
def in_library(monkeypatch):
"""Lidarr holds the artist and the album."""
monkeypatch.setattr(mf, "ensure_album", lambda album, root, search_all, dry_run: {
"album": {"id": 5}, "album_id": 5, "artist_name": "Avril Lavigne",
"album_title": "Under My Skin", "artist_state": "existing",
"album_state": "existing"})
def test_perform_lidarr_album_offers_choices_by_default(monkeypatch, in_library):
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release("a"), _release("b")])
monkeypatch.setattr(mf, "grab_release", lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("must not grab before the user picks")))
hit = make_lidarr_album_hit()
result = actions.perform_fetch(hit, [hit], quality="best", root="/media/music")
assert result["outcome"] == "choices"
assert [c["n"] for c in result["choices"]] == [1, 2]
assert result["pending"] == [_release("a"), _release("b")]
assert result["lidarr_album_id"] == 5
assert result["path"] is None
def test_perform_lidarr_album_fallsthrough_to_youtube(monkeypatch):
monkeypatch.setattr(mf, "act_lidarr_album",
lambda hit, root, search_all, dry_run: False)
def test_perform_lidarr_album_auto_grabs_the_best_release(monkeypatch, in_library):
grabbed = {}
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release("best"), _release("b")])
monkeypatch.setattr(mf, "grab_release",
lambda rel, dry_run=False: grabbed.update(guid=rel["guid"]) or True)
hit = make_lidarr_album_hit()
result = actions.perform_fetch(hit, [hit], quality="best", root="/media/music", auto=True)
assert result["outcome"] == "album_grabbed"
assert grabbed["guid"] == "best"
assert not result.get("choices")
def test_perform_lidarr_album_fallsthrough_to_youtube(monkeypatch, in_library):
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
yt_calls = {}
monkeypatch.setattr(mf, "act_youtube",
lambda hit, root, quality, dry_run: yt_calls.update(hit=hit))
@@ -70,14 +99,35 @@ def test_perform_lidarr_album_fallsthrough_to_youtube(monkeypatch):
result = actions.perform_fetch(lidarr_hit, [lidarr_hit, yt_hit],
quality="best", root="/media/music")
assert yt_calls["hit"] is yt_hit
assert result["outcome"] == "youtube_fallback"
assert result["path"] == "/media/music/Avril Lavigne/youtube"
def test_perform_lidarr_album_no_release_no_fallback_raises(monkeypatch):
monkeypatch.setattr(mf, "act_lidarr_album",
lambda hit, root, search_all, dry_run: False)
def test_perform_lidarr_album_reports_no_release_without_a_fallback(monkeypatch, in_library):
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
hit = make_lidarr_album_hit()
with pytest.raises(RuntimeError):
result = actions.perform_fetch(hit, [hit], quality="best", root="/media/music")
assert result["outcome"] == "no_release"
def test_perform_lidarr_album_skips_youtube_when_not_allowed(monkeypatch, in_library):
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
monkeypatch.setattr(mf, "act_youtube", lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("source=lidarr must not use YouTube")))
lidarr_hit = make_lidarr_album_hit()
result = actions.perform_fetch(lidarr_hit, [lidarr_hit, make_yt_hit()],
quality="best", root="/media/music",
allow_youtube=False)
assert result["outcome"] == "no_release"
def test_perform_lidarr_album_raises_when_the_album_is_not_in_lidarr(monkeypatch):
monkeypatch.setattr(mf, "ensure_album", lambda album, root, search_all, dry_run: {
"album": None, "album_id": None, "artist_name": "Avril Lavigne",
"album_title": "Under My Skin", "artist_state": "existing",
"album_state": "missing"})
hit = make_lidarr_album_hit()
with pytest.raises(RuntimeError, match="no album called"):
actions.perform_fetch(hit, [hit], quality="best", root="/media/music")

View File

@@ -23,7 +23,7 @@ def test_fetch_returns_job_and_message(client, auth, monkeypatch):
monkeypatch.setattr("server.app.mf.pick",
lambda hits, q, noninteractive, yt_first: hits[0])
monkeypatch.setattr("server.app.actions.perform_fetch",
lambda chosen, hits, quality, root: {"path": "/media/music/x", "lidarr_album_id": None})
lambda chosen, hits, quality, root, **kw: {"path": "/media/music/x", "lidarr_album_id": None})
r = client.post("/fetch", params={"q": "Under My Skin"}, headers=auth)
assert r.status_code == 200
@@ -55,7 +55,7 @@ def test_job_lifecycle_done(client, auth, monkeypatch):
monkeypatch.setattr("server.app.mf.pick",
lambda hits, q, noninteractive, yt_first: hits[0])
monkeypatch.setattr("server.app.actions.perform_fetch",
lambda chosen, hits, quality, root: {"path": "/media/music/x", "lidarr_album_id": None})
lambda chosen, hits, quality, root, **kw: {"path": "/media/music/x", "lidarr_album_id": None})
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
end = time.time() + 2

View File

@@ -69,7 +69,7 @@ def test_search_query_still_works(client, auth, monkeypatch):
lambda q, limit, yt_first, lidarr_only, yt_only: [hit])
monkeypatch.setattr("server.app.mf.pick", lambda hits, q, ni, yf: hits[0])
monkeypatch.setattr("server.app.actions.perform_fetch",
lambda chosen, hits, quality, root: {"path": "/x", "lidarr_album_id": None})
lambda chosen, hits, quality, root, **kw: {"path": "/x", "lidarr_album_id": None})
r = client.post("/fetch", params={"q": "Daft Punk - Discovery"}, headers=auth)
assert r.status_code == 200
assert r.json()["status"] == "queued"
@@ -86,7 +86,7 @@ def test_non_direct_link_resolves_and_fetches(client, auth, monkeypatch):
lambda url, limit: ("ODESZA - Bloom", [lid, yt]))
monkeypatch.setattr("server.app.mf.pick", lambda hits, q, ni, yf: hits[0])
monkeypatch.setattr("server.app.actions.perform_fetch",
lambda chosen, hits, quality, root: {"path": None, "lidarr_album_id": 9})
lambda chosen, hits, quality, root, **kw: {"path": None, "lidarr_album_id": 9})
r = client.post("/fetch", params={"q": "https://open.spotify.com/track/abc"}, headers=auth)
assert r.status_code == 200
body = r.json()

View File

@@ -61,3 +61,84 @@ def test_run_job_callable_done_message():
def teardown_module():
jobs.JOBS.clear()
# --- choice jobs -----------------------------------------------------------
def _choices_result():
return {"artist": "Overgrown", "album": "Lay Cold",
"choices": [{"n": 1, "label": "FLAC"}, {"n": 2, "label": "MP3"}],
"pending": [{"guid": "a"}, {"guid": "b"}]}
def test_run_choice_job_parks_the_job():
job = jobs.create_job(hit=None, message="m")
jobs.run_choice_job(job.id, _choices_result,
choice_message=lambda res: "Which one?",
done_message="done!")
j = _wait(job.id, "awaiting_choice")
assert j.message == "Which one?"
assert j.choices == [{"n": 1, "label": "FLAC"}, {"n": 2, "label": "MP3"}]
assert j.expires_at is not None
# The raw indexer data stays server-side only.
assert j.pending == [{"guid": "a"}, {"guid": "b"}]
assert "pending" not in j.result
assert "choices" not in j.result
def test_run_choice_job_finishes_when_there_is_nothing_to_choose():
job = jobs.create_job(hit=None, message="m")
jobs.run_choice_job(job.id, lambda: {"outcome": "no_release", "choices": [], "pending": []},
choice_message=lambda res: "never asked",
done_message=lambda res: f"nothing: {res['outcome']}")
j = _wait(job.id, "done")
assert j.message == "nothing: no_release"
assert j.choices is None
def test_run_choice_job_records_a_failure_with_the_real_reason():
def boom():
raise RuntimeError("Lidarr said no")
job = jobs.create_job(hit=None, message="m")
jobs.run_choice_job(job.id, boom, choice_message=lambda res: "x",
done_message="done!", fail_message=lambda e: str(e))
j = _wait(job.id, "failed")
assert j.message == "Lidarr said no"
assert "RuntimeError" in j.error
def test_take_choice_can_only_be_used_once():
job = jobs.create_job(hit=None, message="m")
jobs.run_choice_job(job.id, _choices_result, choice_message=lambda res: "?",
done_message="done!")
_wait(job.id, "awaiting_choice")
_, item = jobs.take_choice(job.id, 2)
assert item == {"guid": "b"}
assert jobs.get_job(job.id).status == "queued"
try:
jobs.take_choice(job.id, 1)
except jobs.ChoiceError as e:
assert "not waiting for a choice" in str(e)
else:
raise AssertionError("a second take_choice must be refused")
def test_take_choice_rejects_a_number_out_of_range():
job = jobs.create_job(hit=None, message="m")
jobs.run_choice_job(job.id, _choices_result, choice_message=lambda res: "?",
done_message="done!")
_wait(job.id, "awaiting_choice")
try:
jobs.take_choice(job.id, 3)
except jobs.ChoiceError as e:
assert "between 1 and 2" in str(e)
else:
raise AssertionError("out-of-range must be refused")
def test_take_choice_on_an_unknown_job_raises_key_error():
try:
jobs.take_choice("nope", 1)
except KeyError:
pass
else:
raise AssertionError("unknown job must raise KeyError")

250
tests/test_lidarr_choose.py Normal file
View File

@@ -0,0 +1,250 @@
"""The two-step Siri flow on the single entry point: free text at POST /fetch
parks a choice list, and POST /choose sends the picked release to the download
client. A URL grabs directly instead — see test_api_url.py."""
import time
import pytest
from server import actions, jobs as jobs_mod, mf
@pytest.fixture(autouse=True)
def _clear_jobs():
jobs_mod.JOBS.clear()
yield
jobs_mod.JOBS.clear()
def _wait(client, auth, job_id, status, timeout=3.0):
end = time.time() + timeout
while time.time() < end:
body = client.get(f"/jobs/{job_id}", headers=auth).json()
if body["status"] == status:
return body
time.sleep(0.01)
raise AssertionError(f"job {job_id} never reached {status}")
def _release(guid="g1", seeders=24, quality="FLAC"):
return {"guid": guid, "indexerId": 2, "title": f"Overgrown-Lay_Cold-{guid}",
"size": 312 * 1024 * 1024, "seeders": seeders, "indexer": "Redacted",
"quality": {"quality": {"name": quality}}}
def _album_hit():
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
artist="Overgrown",
payload={"album": {"title": "Lay Cold",
"artist": {"artistName": "Overgrown"}}})
@pytest.fixture
def lidarr(monkeypatch):
"""A Lidarr that already holds Overgrown and offers two releases."""
state = {"grabbed": [], "releases": [_release("a", 90), _release("b", 10, "MP3-320")],
"hits": [_album_hit()]}
monkeypatch.setattr("server.app.mf.build_combined_hits",
lambda q, limit, yt_first, lidarr_only, yt_only: state["hits"])
monkeypatch.setattr(actions.mf, "ensure_album", lambda album, root, search_all, dry_run: {
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
monkeypatch.setattr(actions.mf, "list_releases", lambda album_id, **kw: state["releases"])
monkeypatch.setattr(actions.mf, "grab_release",
lambda rel, dry_run=False: state["grabbed"].append(rel["guid"]) or True)
return state
# --- find ------------------------------------------------------------------
def test_find_returns_a_job_immediately(client, auth, lidarr):
r = client.post("/fetch", params={"q": "Overgrown - Lay Cold"}, headers=auth)
assert r.status_code == 200
body = r.json()
assert body["status"] == "queued"
assert body["job_id"]
assert "Searching indexers now." in body["message"]
assert body["hit"]["album"] == "Lay Cold"
assert "choices" not in body
def test_find_parks_the_job_with_a_choice_list(client, auth, lidarr):
job_id = client.post("/fetch", params={"q": "Overgrown - Lay Cold"},
headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "awaiting_choice")
assert "Which one?" in body["message"]
labels = [c["label"] for c in body["choices"]]
assert labels == ["FLAC, 312 MB, 90 seeders, Redacted",
"MP3-320, 312 MB, 10 seeders, Redacted"]
assert [c["n"] for c in body["choices"]] == [1, 2]
# Shortcuts can only "Choose from List" plain strings, so each choice also
# carries a numbered label the client can read the number back off.
assert [c["pick_label"] for c in body["choices"]] == [
"1. FLAC, 312 MB, 90 seeders, Redacted",
"2. MP3-320, 312 MB, 10 seeders, Redacted"]
assert lidarr["grabbed"] == [] # nothing sent until the user picks
def test_find_choices_never_leak_the_raw_guid(client, auth, lidarr):
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "awaiting_choice")
assert all("guid" not in c for c in body["choices"])
assert "guid" not in str(body["result"])
assert "pending" not in body["result"]
def test_find_caps_the_list(client, auth, lidarr, monkeypatch):
lidarr["releases"] = [_release(f"g{i}") for i in range(30)]
monkeypatch.setattr(actions, "MAX_CHOICES", 5)
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "awaiting_choice")
assert len(body["choices"]) == 5
def test_find_auto_grabs_the_best_release_without_asking(client, auth, lidarr):
job_id = client.post("/fetch", params={"q": "x", "auto": "true"},
headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "done")
assert lidarr["grabbed"] == ["a"]
assert "download client" in body["message"]
def test_find_reports_no_release_without_pretending_to_download(client, auth, lidarr):
lidarr["releases"] = [] # Lidarr-only: no YouTube hit to fall back to
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "done")
assert "no indexer has a release" in body["message"]
def test_a_url_grabs_directly_and_never_asks(client, auth, lidarr, monkeypatch):
""""If a URL, grab directly" — the choice list is for free text only."""
monkeypatch.setattr("server.app.mf._is_direct_url", lambda url: False)
monkeypatch.setattr("server.app.mf.resolve_link_hits",
lambda url, limit: ("Overgrown - Lay Cold", lidarr["hits"]))
job_id = client.post("/fetch", params={"q": "https://open.spotify.com/track/abc"},
headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "done")
assert lidarr["grabbed"] == ["a"]
assert "download client" in body["message"]
def test_youtube_source_skips_lidarr(client, auth, lidarr, monkeypatch):
yt_hit = mf.Hit(source="youtube", kind="track", title="Lay Cold", artist="Overgrown",
album="Lay Cold", payload={"videoId": "abc"})
lidarr["hits"] = [yt_hit]
monkeypatch.setattr(actions.mf, "act_youtube", lambda *a, **kw: True)
job_id = client.post("/fetch", params={"q": "x", "source": "youtube"},
headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "done")
assert lidarr["grabbed"] == []
assert body["message"] == "Finished downloading 'Lay Cold' by Overgrown."
def test_find_adds_a_missing_artist_only(client, auth, lidarr, monkeypatch):
added = []
lidarr["hits"] = [mf.Hit(source="lidarr", kind="artist", title="Overgrown",
artist="Overgrown",
payload={"artist": {"artistName": "Overgrown"}})]
monkeypatch.setattr(actions.mf, "ensure_artist", lambda meta, root, search_all, dry_run:
(added.append(meta["artistName"]),
{"artist": {"id": 9}, "state": "added", "name": "Overgrown"})[1])
job_id = client.post("/fetch", params={"q": "Overgrown"}, headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "done")
assert added == ["Overgrown"]
assert "Added Overgrown to Lidarr" in body["message"]
assert lidarr["grabbed"] == []
def test_no_hits_is_a_404_not_a_job(client, auth, lidarr):
lidarr["hits"] = []
r = client.post("/fetch", params={"q": "zzz"}, headers=auth)
assert r.status_code == 404
assert "zzz" in r.json()["message"]
def test_failure_message_explains_why(client, auth, lidarr, monkeypatch):
monkeypatch.setattr(actions.mf, "ensure_album", lambda album, root, search_all, dry_run: {
"album": None, "album_id": None, "artist_name": "Overgrown",
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "missing"})
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
body = _wait(client, auth, job_id, "failed")
assert "no album called 'Lay Cold'" in body["message"]
def test_find_needs_the_api_key(client, lidarr):
assert client.post("/fetch", params={"q": "x"}).status_code == 401
# --- choose ----------------------------------------------------------------
def _parked(client, auth, lidarr):
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
_wait(client, auth, job_id, "awaiting_choice")
return job_id
def test_choose_sends_the_picked_release(client, auth, lidarr):
job_id = _parked(client, auth, lidarr)
r = client.post("/choose", params={"job_id": job_id, "n": 2}, headers=auth)
assert r.status_code == 200
assert "Sending 'Lay Cold' by Overgrown" in r.json()["message"]
body = _wait(client, auth, job_id, "done")
assert lidarr["grabbed"] == ["b"]
assert body["message"] == ("Sent 'Lay Cold' by Overgrown to the download client. "
"MP3-320, 312 MB, 10 seeders, Redacted.")
assert "choices" not in body
def test_choose_twice_is_refused(client, auth, lidarr):
job_id = _parked(client, auth, lidarr)
client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
_wait(client, auth, job_id, "done")
r = client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
assert r.status_code == 409
assert lidarr["grabbed"] == ["a"]
def test_choose_out_of_range_is_refused(client, auth, lidarr):
job_id = _parked(client, auth, lidarr)
r = client.post("/choose", params={"job_id": job_id, "n": 9}, headers=auth)
assert r.status_code == 409
assert "between 1 and 2" in r.json()["message"]
assert lidarr["grabbed"] == []
def test_choose_unknown_job_is_404(client, auth):
r = client.post("/choose", params={"job_id": "nope", "n": 1}, headers=auth)
assert r.status_code == 404
def test_the_old_split_endpoints_are_gone(client, auth):
assert client.post("/lidarr/find", params={"q": "x"}, headers=auth).status_code == 404
assert client.post("/lidarr/choose", params={"job_id": "x", "n": 1},
headers=auth).status_code == 404
def test_choose_zero_is_rejected_by_validation(client, auth, lidarr):
job_id = _parked(client, auth, lidarr)
assert client.post("/choose", params={"job_id": job_id, "n": 0},
headers=auth).status_code == 422
def test_choose_needs_the_api_key(client, auth, lidarr):
job_id = _parked(client, auth, lidarr)
assert client.post("/choose", params={"job_id": job_id, "n": 1}).status_code == 401
def test_a_parked_job_expires(client, auth, lidarr, monkeypatch):
job_id = _parked(client, auth, lidarr)
jobs_mod.JOBS[job_id].expires_at = time.time() - 1
body = client.get(f"/jobs/{job_id}", headers=auth).json()
assert body["status"] == "expired"
assert "expired" in body["message"]
r = client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
assert r.status_code == 409
assert lidarr["grabbed"] == []
def test_choose_reports_a_lidarr_refusal(client, auth, lidarr, monkeypatch):
job_id = _parked(client, auth, lidarr)
monkeypatch.setattr(actions.mf, "grab_release", lambda rel, dry_run=False: False)
client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
body = _wait(client, auth, job_id, "failed")
assert "refused the release" in body["message"]

View File

@@ -0,0 +1,268 @@
"""Library-aware Lidarr behaviour: never add a duplicate artist, never grab a
random album, and describe a release in words a person understands."""
import server.mf # noqa: F401 — loads musicfetch, registers musicfetch_core
import musicfetch_core as mf
JACKO = {"id": 3, "artistName": "Michael Jackson", "foreignArtistId": "mj-mbid"}
def _no_add(*_a, **_kw):
raise AssertionError("add_artist must not be called for an existing artist")
# --- find_library_artist / ensure_artist -----------------------------------
def test_find_library_artist_matches_name_case_insensitively(monkeypatch):
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
assert mf.find_library_artist(name="michael jackson") == JACKO
def test_find_library_artist_matches_mbid_when_name_differs(monkeypatch):
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
assert mf.find_library_artist(name="Jacko", foreign_id="mj-mbid") == JACKO
def test_find_library_artist_returns_none_when_absent(monkeypatch):
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
assert mf.find_library_artist(name="Overgrown") is None
def test_ensure_artist_never_adds_a_duplicate(monkeypatch):
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
monkeypatch.setattr(mf, "add_artist", _no_add)
res = mf.ensure_artist({"artistName": "Michael Jackson"}, "/media/music", False, False)
assert res["state"] == "existing"
assert res["artist"] == JACKO
assert res["name"] == "Michael Jackson"
def test_ensure_artist_adds_when_missing(monkeypatch):
monkeypatch.setattr(mf, "library_artists", lambda: [])
monkeypatch.setattr(mf, "add_artist",
lambda meta, root, search_all, dry_run: {"id": 9, "artistName": "Overgrown"})
res = mf.ensure_artist({"artistName": "Overgrown", "foreignArtistId": "og"},
"/media/music", False, False)
assert res["state"] == "added"
assert res["artist"]["id"] == 9
def test_ensure_artist_reports_failure(monkeypatch):
monkeypatch.setattr(mf, "library_artists", lambda: [])
monkeypatch.setattr(mf, "add_artist", lambda *a, **kw: None)
res = mf.ensure_artist({"artistName": "Overgrown"}, "/media/music", False, False)
assert res["state"] == "failed"
assert res["artist"] is None
def test_act_lidarr_artist_does_not_add_existing(monkeypatch, capsys):
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
monkeypatch.setattr(mf, "add_artist", _no_add)
hit = mf.Hit(source="lidarr", kind="artist", title="Michael Jackson",
artist="Michael Jackson", payload={"artist": {"artistName": "Michael Jackson"}})
assert mf.act_lidarr_artist(hit, "/media/music", False, False) is True
assert "already in Lidarr" in capsys.readouterr().out
# --- album matching --------------------------------------------------------
LIB_ALBUMS = [
{"id": 11, "title": "Off the Wall", "foreignAlbumId": "otw"},
{"id": 12, "title": "Thriller (Deluxe Edition)", "foreignAlbumId": "thr-deluxe"},
]
def test_match_album_prefers_the_musicbrainz_id():
got = mf._match_album(LIB_ALBUMS, {"title": "Something Else", "foreignAlbumId": "otw"})
assert got["id"] == 11
def test_match_album_matches_title_ignoring_case_and_punctuation():
got = mf._match_album(LIB_ALBUMS, {"title": "off the wall!"})
assert got["id"] == 11
def test_match_album_ignores_an_edition_suffix():
got = mf._match_album(LIB_ALBUMS, {"title": "Thriller"})
assert got["id"] == 12
def test_match_album_returns_none_instead_of_a_random_album():
assert mf._match_album(LIB_ALBUMS, {"title": "Bad"}) is None
def test_ensure_album_reports_missing_album_rather_than_guessing(monkeypatch):
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: LIB_ALBUMS)
res = mf.ensure_album({"title": "Bad", "artist": {"artistName": "Michael Jackson"}},
"/media/music", False, False)
assert res["album"] is None
assert res["album_state"] == "missing"
assert res["artist_state"] == "existing"
def test_ensure_album_monitors_an_unmonitored_album(monkeypatch):
album = {"id": 11, "title": "Off the Wall", "monitored": False}
puts = []
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: [album])
monkeypatch.setattr(mf, "lidarr_put", lambda path, payload, **kw: puts.append(payload) or {})
res = mf.ensure_album({"title": "Off the Wall", "artist": {"artistName": "Michael Jackson"}},
"/media/music", False, False)
assert res["album_state"] == "monitored"
assert res["album_id"] == 11
assert puts == [{"albumIds": [11], "monitored": True}]
def test_ensure_album_leaves_a_monitored_album_alone(monkeypatch):
album = {"id": 11, "title": "Off the Wall", "monitored": True}
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: [album])
monkeypatch.setattr(mf, "lidarr_put", lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("must not re-monitor")))
res = mf.ensure_album({"title": "Off the Wall", "artist": {"artistName": "Michael Jackson"}},
"/media/music", False, False)
assert res["album_state"] == "existing"
# --- releases --------------------------------------------------------------
def _release(**kw):
base = {"guid": "g1", "indexerId": 2, "title": "Overgrown-Lay_Cold-WEB-2023",
"size": 312 * 1024 * 1024, "seeders": 24, "indexer": "Redacted",
"quality": {"quality": {"name": "FLAC"}}}
base.update(kw)
return base
def test_release_label_is_readable():
label = mf.release_label(_release())
assert label == "FLAC, 312 MB, 24 seeders, Redacted"
def test_release_label_uses_gigabytes_for_big_files():
assert "2.0 GB" in mf.release_label(_release(size=2 * 1024 * 1024 * 1024))
def test_release_label_flags_a_rejected_release():
label = mf.release_label(_release(rejected=True, rejections=["Not a preferred word"]))
assert "not recommended" in label
assert "Not a preferred word" in label
def test_release_label_falls_back_to_the_raw_title():
assert mf.release_label({"title": "Weird.Release"}) == "Weird.Release"
def test_sort_releases_puts_rejected_last_and_best_seeded_first():
low = _release(guid="low", seeders=2)
high = _release(guid="high", seeders=90)
bad = _release(guid="bad", seeders=500, rejected=True)
order = [r["guid"] for r in mf.sort_releases([low, bad, high])]
assert order == ["high", "low", "bad"]
def test_list_releases_skips_a_call_without_a_real_album_id():
assert mf.list_releases(0) == []
assert mf.list_releases(None) == []
def test_grab_release_posts_the_exact_guid_and_indexer(monkeypatch):
posted = {}
monkeypatch.setattr(mf, "lidarr_post",
lambda path, payload, **kw: posted.update(path=path, payload=payload) or {})
assert mf.grab_release(_release()) is True
assert posted["path"] == "/api/v1/release"
assert posted["payload"] == {"guid": "g1", "indexerId": 2}
def test_grab_release_refuses_an_incomplete_release(monkeypatch):
monkeypatch.setattr(mf, "lidarr_post", lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("must not post")))
assert mf.grab_release({"title": "no guid"}) is False
# --- album dispatch --------------------------------------------------------
def _album_hit():
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
artist="Overgrown",
payload={"album": {"title": "Lay Cold",
"artist": {"artistName": "Overgrown"}}})
def test_act_lidarr_album_grabs_the_best_release(monkeypatch, capsys):
grabbed = {}
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release(guid="best")])
monkeypatch.setattr(mf, "grab_release",
lambda rel, dry_run=False: grabbed.update(guid=rel["guid"]) or True)
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False) is True
assert grabbed["guid"] == "best"
out = capsys.readouterr().out
assert "Overgrown is already in Lidarr." in out
assert "download client" in out
def test_act_lidarr_album_falls_through_when_no_indexer_has_it(monkeypatch, capsys):
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
"album_title": "Lay Cold", "artist_state": "added", "album_state": "monitored"})
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False) is False
out = capsys.readouterr().out
assert "Added Overgrown to Lidarr." in out
assert "Now monitoring" in out
def test_act_lidarr_album_uses_the_picked_release(monkeypatch):
grabbed = {}
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
monkeypatch.setattr(mf, "list_releases",
lambda album_id, **kw: [_release(guid="a"), _release(guid="b")])
monkeypatch.setattr(mf, "pick_release", lambda releases: releases[1])
monkeypatch.setattr(mf, "grab_release",
lambda rel, dry_run=False: grabbed.update(guid=rel["guid"]) or True)
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False,
choose_release=True) is True
assert grabbed["guid"] == "b"
def test_act_lidarr_album_sends_nothing_when_the_user_quits(monkeypatch):
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release()])
monkeypatch.setattr(mf, "pick_release", lambda releases: None)
monkeypatch.setattr(mf, "grab_release", lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("must not grab")))
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False,
choose_release=True) is True
# --- guards ----------------------------------------------------------------
def test_norm_title_keeps_accented_letters():
assert mf._norm_title("Björk") == "björk"
assert mf._norm_title("AC/DC") == "ac dc"
def test_base_title_of_an_all_bracket_title_matches_nothing():
assert mf._base_title("(Untitled)") == ""
assert mf._match_album([{"id": 1, "title": "(Whatever)"}], {"title": "(Untitled)"}) is None
def test_set_album_monitored_refuses_an_album_without_a_library_id(monkeypatch):
monkeypatch.setattr(mf, "lidarr_put", lambda *a, **kw: (_ for _ in ()).throw(
AssertionError("must not PUT")))
assert mf.set_album_monitored({"title": "Lay Cold"}, False) is False
def test_ensure_album_stops_when_lidarr_gives_no_artist_id(monkeypatch):
monkeypatch.setattr(mf, "library_artists", lambda: [])
monkeypatch.setattr(mf, "add_artist", lambda *a, **kw: {"artistName": "Overgrown"})
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: (_ for _ in ()).throw(
AssertionError("must not list every album")))
res = mf.ensure_album({"title": "Lay Cold", "artist": {"artistName": "Overgrown"}},
"/media/music", False, False)
assert res["album"] is None
assert res["album_state"] == "missing"

View File

@@ -0,0 +1,120 @@
"""The spoken message must match what the server really did."""
from server import actions, mf
def _artist_hit(name="Michael Jackson"):
return mf.Hit(source="lidarr", kind="artist", title=name, artist=name,
payload={"artist": {"artistName": name}})
def _album_hit():
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
artist="Overgrown", payload={"album": {"title": "Lay Cold"}})
def _yt_hit():
return mf.Hit(source="youtube", kind="track", title="Lay Cold", artist="Overgrown",
album="Lay Cold", payload={"videoId": "abc"})
def test_artist_message_never_says_x_by_x():
"""The old bug: "Finished downloading 'Michael Jackson' by Michael Jackson."."""
hit = _artist_hit()
for msg in (actions.started_message(hit),
actions.done_message(hit, {"outcome": "artist_added"}),
actions.done_message(hit, {"outcome": "artist_existing"}),
actions.failed_message(hit)):
assert "'Michael Jackson' by Michael Jackson" not in msg
def test_artist_added_message_does_not_claim_a_download():
msg = actions.done_message(_artist_hit(), {"outcome": "artist_added"})
assert "Added Michael Jackson to Lidarr" in msg
assert "Nothing was downloaded" in msg
assert "Finished downloading" not in msg
def test_artist_existing_message_says_nothing_was_added():
msg = actions.done_message(_artist_hit(), {"outcome": "artist_existing"})
assert "already in Lidarr" in msg
assert "Nothing was added" in msg
def test_album_grabbed_message_says_download_client():
"""Falls back to the hit when the result does not name the album."""
msg = actions.done_message(_album_hit(), {"outcome": "album_grabbed"})
assert "Sent 'Lay Cold' by Overgrown to the download client." == msg
def test_youtube_fallback_message_names_youtube():
"""True whether no indexer had it or Lidarr refused the release."""
msg = actions.done_message(_album_hit(), {"outcome": "youtube_fallback"})
assert "Lidarr could not get 'Lay Cold' by Overgrown" in msg
assert "YouTube Music" in msg
def test_started_message_for_an_artist_does_not_say_downloading():
assert actions.started_message(_artist_hit()) == "Looking up Michael Jackson in Lidarr."
def test_started_message_for_a_lidarr_album_says_searching_indexers():
msg = actions.started_message(_album_hit())
assert "Searching indexers now." in msg
def test_youtube_messages_are_unchanged():
assert actions.started_message(_yt_hit()).startswith("Found 'Lay Cold' by Overgrown on YouTube Music")
assert actions.done_message(_yt_hit()) == "Finished downloading 'Lay Cold' by Overgrown."
def test_choice_message_asks_which_one():
msg = actions.choice_message(_album_hit(), {
"artist": "Overgrown", "album": "Lay Cold", "artist_state": "existing",
"album_state": "existing", "choices": [{"n": 1}, {"n": 2}]})
assert "Overgrown is already in Lidarr." in msg
assert "I found 2 releases for 'Lay Cold' by Overgrown. Which one?" in msg
def test_choice_message_mentions_a_new_artist_and_new_monitor():
msg = actions.choice_message(_album_hit(), {
"artist": "Overgrown", "album": "Lay Cold", "artist_state": "added",
"album_state": "monitored", "choices": [{"n": 1}]})
assert "Added Overgrown to Lidarr." in msg
assert "Now monitoring 'Lay Cold'." in msg
assert "I found 1 release for" in msg
def test_choice_message_falls_back_to_the_hit():
msg = actions.choice_message(_album_hit(), {"choices": [{"n": 1}]})
assert "'Lay Cold' by Overgrown" in msg
def test_no_release_message_is_honest():
msg = actions.done_message(_album_hit(), {
"outcome": "no_release", "artist": "Overgrown", "album": "Lay Cold",
"artist_state": "added", "album_state": "monitored"})
assert "no indexer has a release" in msg
assert "download" not in msg.lower()
def test_album_grabbed_message_names_the_release_when_known():
msg = actions.done_message(_album_hit(), {
"outcome": "album_grabbed", "artist": "Overgrown", "album": "Lay Cold",
"release": "FLAC, 312 MB, 24 seeders, Redacted"})
assert msg == ("Sent 'Lay Cold' by Overgrown to the download client. "
"FLAC, 312 MB, 24 seeders, Redacted.")
def test_fail_text_speaks_a_fetch_error_and_hides_anything_else():
hit = _album_hit()
assert actions.fail_text(hit, actions.FetchError("Overgrown is in Lidarr, but ...")) \
== "Overgrown is in Lidarr, but ..."
assert actions.fail_text(hit, OSError("[Errno 2] No such file: 'yt-dlp'")) \
== "Failed to fetch 'Lay Cold' by Overgrown."
def test_grab_message_names_the_release():
msg = actions.grab_message({"artist": "Overgrown", "album": "Lay Cold",
"release": "FLAC, 312 MB, 24 seeders, Redacted"})
assert msg == ("Sent 'Lay Cold' by Overgrown to the download client. "
"FLAC, 312 MB, 24 seeders, Redacted.")