From b3e6f66097d97835096fe7814d6abcfe996f4d92 Mon Sep 17 00:00:00 2001 From: zebra Date: Sun, 4 Oct 2026 20:28:24 -0700 Subject: [PATCH] feat: rework Lidarr library integration --- .dockerignore | 1 + .gitattributes | 63 +++ .github/workflows/docker-publish.yml | 93 +++++ README.md | 132 +++++- .../specs/2026-09-18-lidarr-rework-design.md | 126 ++++++ musicfetch | 393 +++++++++++++++--- server/actions.py | 183 +++++++- server/app.py | 62 ++- server/jobs.py | 102 ++++- server/mf.py | 12 +- tests/test_actions.py | 72 +++- tests/test_api.py | 4 +- tests/test_api_url.py | 4 +- tests/test_jobs.py | 81 ++++ tests/test_lidarr_choose.py | 250 +++++++++++ tests/test_lidarr_library.py | 268 ++++++++++++ tests/test_lidarr_messages.py | 120 ++++++ 17 files changed, 1839 insertions(+), 127 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/docker-publish.yml create mode 100644 docs/superpowers/specs/2026-09-18-lidarr-rework-design.md create mode 100644 tests/test_lidarr_choose.py create mode 100644 tests/test_lidarr_library.py create mode 100644 tests/test_lidarr_messages.py diff --git a/.dockerignore b/.dockerignore index daf9524..23fad09 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,5 +3,6 @@ __pycache__/ tests/ docs/ .git/ +.github/ *.md .claude/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5896c16 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..1b807c0 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/README.md b/README.md index 8332b76..87f5d07 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/superpowers/specs/2026-09-18-lidarr-rework-design.md b/docs/superpowers/specs/2026-09-18-lidarr-rework-design.md new file mode 100644 index 0000000..bed7031 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-lidarr-rework-design.md @@ -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. diff --git a/musicfetch b/musicfetch index a498680..0c9c414 100755 --- a/musicfetch +++ b/musicfetch @@ -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__": diff --git a/server/actions.py b/server/actions.py index d2215b3..f800af6 100644 --- a/server/actions.py +++ b/server/actions.py @@ -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) diff --git a/server/app.py b/server/app.py index 2b371b8..0ff1dd3 100644 --- a/server/app.py +++ b/server/app.py @@ -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 diff --git a/server/jobs.py b/server/jobs.py index 918cee6..42251ee 100644 --- a/server/jobs.py +++ b/server/jobs.py @@ -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) diff --git a/server/mf.py b/server/mf.py index c688e3f..dd78daf 100644 --- a/server/mf.py +++ b/server/mf.py @@ -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", diff --git a/tests/test_actions.py b/tests/test_actions.py index 422b90a..9e49453 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -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") diff --git a/tests/test_api.py b/tests/test_api.py index 8c63d15..6f51acb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_api_url.py b/tests/test_api_url.py index ec841fa..366609e 100644 --- a/tests/test_api_url.py +++ b/tests/test_api_url.py @@ -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() diff --git a/tests/test_jobs.py b/tests/test_jobs.py index d61f80f..bdca27a 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -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") diff --git a/tests/test_lidarr_choose.py b/tests/test_lidarr_choose.py new file mode 100644 index 0000000..95ecf21 --- /dev/null +++ b/tests/test_lidarr_choose.py @@ -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"] diff --git a/tests/test_lidarr_library.py b/tests/test_lidarr_library.py new file mode 100644 index 0000000..44127dc --- /dev/null +++ b/tests/test_lidarr_library.py @@ -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" diff --git a/tests/test_lidarr_messages.py b/tests/test_lidarr_messages.py new file mode 100644 index 0000000..e7fa1e9 --- /dev/null +++ b/tests/test_lidarr_messages.py @@ -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.")