feat: rework Lidarr library integration
This commit is contained in:
@@ -3,5 +3,6 @@ __pycache__/
|
||||
tests/
|
||||
docs/
|
||||
.git/
|
||||
.github/
|
||||
*.md
|
||||
.claude/
|
||||
|
||||
63
.gitattributes
vendored
Normal file
63
.gitattributes
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
93
.github/workflows/docker-publish.yml
vendored
Normal file
93
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
statuses: write
|
||||
|
||||
env:
|
||||
IMAGE_REGISTRY: gitea.theannex.dev/izebra_projects/musicfetch
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.GITEA_USERNAME }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
registry: gitea.theannex.dev
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_REGISTRY }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha
|
||||
type=sha,format=short
|
||||
type=ref,event=branch
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.repositoryUrl }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
||||
org.opencontainers.image.title=musicfetch
|
||||
org.opencontainers.image.description=Automated build from commit ${{ github.sha }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build_push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: server/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_REGISTRY }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE_REGISTRY }}:buildcache,mode=max
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Set commit status (image pushed)
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const sha = context.sha;
|
||||
const tags = `${process.env.IMAGE_REGISTRY}:latest`;
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha,
|
||||
state: 'success',
|
||||
context: 'docker-image',
|
||||
description: `Image pushed with tags: latest, sha`,
|
||||
target_url: `https://${process.env.IMAGE_REGISTRY}`
|
||||
});
|
||||
|
||||
- name: Post build summary
|
||||
run: |
|
||||
echo "### Docker Image Build" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Registry: $IMAGE_REGISTRY" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Commit: $GITHUB_SHA" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tags applied:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
||||
132
README.md
132
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
|
||||
|
||||
126
docs/superpowers/specs/2026-09-18-lidarr-rework-design.md
Normal file
126
docs/superpowers/specs/2026-09-18-lidarr-rework-design.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Lidarr Rework — Design
|
||||
|
||||
**Date:** 2026-09-18
|
||||
**Status:** Implemented
|
||||
|
||||
## Context & Goal
|
||||
|
||||
The Lidarr side of MusicFetch said things that were not true, and it always
|
||||
tried to add an artist that the library already held. It also let Lidarr decide
|
||||
which indexer release to grab. This rework makes the Lidarr path honest,
|
||||
library-aware, and able to ask the user which release to take.
|
||||
|
||||
## Bugs this fixes
|
||||
|
||||
1. **Wrong message.** `done_message` used the hit's display title. An artist hit
|
||||
puts the artist name in both slots, so the API said
|
||||
`Finished downloading 'Michael Jackson' by Michael Jackson.` An artist add
|
||||
also downloads nothing, so "Finished downloading" was wrong twice.
|
||||
2. **Always a duplicate artist.** `act_lidarr_artist` posted `/api/v1/artist`
|
||||
without a library check. `get_existing_artist` existed but only ran inside
|
||||
`ensure_album_in_library`. Lidarr then rejected the duplicate and the job
|
||||
failed.
|
||||
3. **The library was invisible to the search.** A bare name went to
|
||||
`/api/v1/artist/lookup`, which searches MusicBrainz, not the local library.
|
||||
4. **Silent wrong album.** `ensure_album_in_library` returned `albums[0]` when no
|
||||
title matched, so MusicFetch could grab an unrelated album.
|
||||
5. **Race after an artist add.** Lidarr fills an artist's album list after the
|
||||
POST returns. The old code read the list at once, found nothing, and hit the
|
||||
`albums[0]` path above.
|
||||
6. **A double search.** The code ran `/api/v1/release` (Lidarr's interactive
|
||||
search, up to 90 s), counted the results, threw the list away, then ran the
|
||||
`AlbumSearch` command, which searched again and picked on its own.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **musicfetch stays a standalone single-file script.** All new Lidarr logic
|
||||
lives in it. The API imports it, as before.
|
||||
- **Match an artist by MusicBrainz id first, then by a normalised name.**
|
||||
`find_library_artist` reads `/api/v1/artist` (the library), not the metadata
|
||||
lookup. `ensure_artist` returns a state: `existing`, `added` or `failed`.
|
||||
- **Match an album by MusicBrainz id, then exact title, then title without an
|
||||
edition suffix. Never by position.** No match returns `None`.
|
||||
- **Poll for the album list after an artist add** (up to 40 s, every 2 s).
|
||||
- **Monitor the album before a grab.** Lidarr refuses a grab for an unmonitored
|
||||
album.
|
||||
- **Grab one exact release.** `POST /api/v1/release {guid, indexerId}` replaces
|
||||
the `AlbumSearch` command, so the user's pick is the release that downloads.
|
||||
The `AlbumSearch` helper is gone — it had no caller left.
|
||||
- **Every message states the real outcome**: `artist_added`, `artist_existing`,
|
||||
`album_grabbed`, `youtube_fallback`, `no_release`.
|
||||
|
||||
## One endpoint
|
||||
|
||||
`POST /fetch` is the only entry point. It decides from `q`:
|
||||
|
||||
- **A URL is a direct grab.** No prompt. `auto=True` is forced.
|
||||
- **Free text goes through Lidarr**, then offers the releases.
|
||||
|
||||
`source` still narrows it: `youtube` skips Lidarr, `lidarr` drops the YouTube
|
||||
fallback.
|
||||
|
||||
A separate `/lidarr/find` was rejected. Two endpoints that both mean "get me
|
||||
this" is a choice the caller should not have to make, and a Shortcut would have
|
||||
to branch on whether the input looks like a URL. `/fetch` already knew how to
|
||||
tell them apart.
|
||||
|
||||
## The two-step choose flow
|
||||
|
||||
Lidarr's interactive search takes 10–90 s. A Siri Shortcut cannot hold an HTTP
|
||||
request open that long, so the search is part of the job and the client polls it.
|
||||
|
||||
```
|
||||
POST /fetch?q=Overgrown - Lay Cold
|
||||
-> {"job_id": "a1b2c3", "status": "queued", "hit": {...}} (returns at once)
|
||||
|
||||
worker: ensure_artist -> ensure_album -> monitor
|
||||
-> list_releases (interactive search)
|
||||
|
||||
GET /jobs/a1b2c3
|
||||
-> {"status": "awaiting_choice",
|
||||
"message": "Overgrown is already in Lidarr. I found 3 releases ... Which one?",
|
||||
"choices": [{"n": 1, "label": "FLAC, 312 MB, 24 seeders, Redacted",
|
||||
"pick_label": "1. FLAC, 312 MB, ...", ...}]}
|
||||
|
||||
POST /choose?job_id=a1b2c3&n=1
|
||||
-> grab_release(guid, indexerId) -> the download client
|
||||
```
|
||||
|
||||
The hit resolution stays in the request, so a query that finds nothing is still
|
||||
a `404` and the response still carries the `hit` for Siri to read back. Only the
|
||||
slow part — ensure, monitor, interactive search — moves into the job.
|
||||
|
||||
A job parks at `awaiting_choice` and holds the raw releases server-side. The
|
||||
client only ever sees `n`, a label and a numbered `pick_label`, so no `guid`
|
||||
leaves the server. A parked job expires after `MUSICFETCH_CHOICE_TTL` seconds
|
||||
(default 900). `?auto=true` grabs the best release and skips the prompt.
|
||||
|
||||
No indexer release means the job never parks. It falls back to the top YouTube
|
||||
hit, as the CLI does, unless `source=lidarr`.
|
||||
|
||||
`MUSICFETCH_MAX_CHOICES` (default 10) caps the list, because a spoken list must
|
||||
stay short. Order is: usable releases first, then most seeders, then largest
|
||||
file.
|
||||
|
||||
## Failure text
|
||||
|
||||
`actions.FetchError` marks a failure whose text is written for a person to hear
|
||||
("Overgrown is in Lidarr, but it has no album called 'Lay Cold'."). The job
|
||||
message uses it. Any other exception falls back to the generic
|
||||
`failed_message`, because a yt-dlp stack trace is not something Siri should read
|
||||
out.
|
||||
|
||||
## CLI parity
|
||||
|
||||
`musicfetch --pick-release` shows the same releases in a table and asks which
|
||||
one. It shares `list_releases`, `release_label` and `grab_release` with the API,
|
||||
so the two paths cannot drift.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Keep `AlbumSearch` after the interactive search.** It ignores the user's
|
||||
pick and searches twice.
|
||||
- **A synchronous search endpoint.** The interactive search is too slow for a
|
||||
Shortcut's HTTP action.
|
||||
- **A separate store for pending choices.** The job store already has the right
|
||||
shape, a TTL and an eviction cap.
|
||||
393
musicfetch
393
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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
102
server/jobs.py
102
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)
|
||||
|
||||
12
server/mf.py
12
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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -61,3 +61,84 @@ def test_run_job_callable_done_message():
|
||||
|
||||
def teardown_module():
|
||||
jobs.JOBS.clear()
|
||||
|
||||
|
||||
# --- choice jobs -----------------------------------------------------------
|
||||
def _choices_result():
|
||||
return {"artist": "Overgrown", "album": "Lay Cold",
|
||||
"choices": [{"n": 1, "label": "FLAC"}, {"n": 2, "label": "MP3"}],
|
||||
"pending": [{"guid": "a"}, {"guid": "b"}]}
|
||||
|
||||
|
||||
def test_run_choice_job_parks_the_job():
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, _choices_result,
|
||||
choice_message=lambda res: "Which one?",
|
||||
done_message="done!")
|
||||
j = _wait(job.id, "awaiting_choice")
|
||||
assert j.message == "Which one?"
|
||||
assert j.choices == [{"n": 1, "label": "FLAC"}, {"n": 2, "label": "MP3"}]
|
||||
assert j.expires_at is not None
|
||||
# The raw indexer data stays server-side only.
|
||||
assert j.pending == [{"guid": "a"}, {"guid": "b"}]
|
||||
assert "pending" not in j.result
|
||||
assert "choices" not in j.result
|
||||
|
||||
|
||||
def test_run_choice_job_finishes_when_there_is_nothing_to_choose():
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, lambda: {"outcome": "no_release", "choices": [], "pending": []},
|
||||
choice_message=lambda res: "never asked",
|
||||
done_message=lambda res: f"nothing: {res['outcome']}")
|
||||
j = _wait(job.id, "done")
|
||||
assert j.message == "nothing: no_release"
|
||||
assert j.choices is None
|
||||
|
||||
|
||||
def test_run_choice_job_records_a_failure_with_the_real_reason():
|
||||
def boom():
|
||||
raise RuntimeError("Lidarr said no")
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, boom, choice_message=lambda res: "x",
|
||||
done_message="done!", fail_message=lambda e: str(e))
|
||||
j = _wait(job.id, "failed")
|
||||
assert j.message == "Lidarr said no"
|
||||
assert "RuntimeError" in j.error
|
||||
|
||||
|
||||
def test_take_choice_can_only_be_used_once():
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, _choices_result, choice_message=lambda res: "?",
|
||||
done_message="done!")
|
||||
_wait(job.id, "awaiting_choice")
|
||||
_, item = jobs.take_choice(job.id, 2)
|
||||
assert item == {"guid": "b"}
|
||||
assert jobs.get_job(job.id).status == "queued"
|
||||
try:
|
||||
jobs.take_choice(job.id, 1)
|
||||
except jobs.ChoiceError as e:
|
||||
assert "not waiting for a choice" in str(e)
|
||||
else:
|
||||
raise AssertionError("a second take_choice must be refused")
|
||||
|
||||
|
||||
def test_take_choice_rejects_a_number_out_of_range():
|
||||
job = jobs.create_job(hit=None, message="m")
|
||||
jobs.run_choice_job(job.id, _choices_result, choice_message=lambda res: "?",
|
||||
done_message="done!")
|
||||
_wait(job.id, "awaiting_choice")
|
||||
try:
|
||||
jobs.take_choice(job.id, 3)
|
||||
except jobs.ChoiceError as e:
|
||||
assert "between 1 and 2" in str(e)
|
||||
else:
|
||||
raise AssertionError("out-of-range must be refused")
|
||||
|
||||
|
||||
def test_take_choice_on_an_unknown_job_raises_key_error():
|
||||
try:
|
||||
jobs.take_choice("nope", 1)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("unknown job must raise KeyError")
|
||||
|
||||
250
tests/test_lidarr_choose.py
Normal file
250
tests/test_lidarr_choose.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""The two-step Siri flow on the single entry point: free text at POST /fetch
|
||||
parks a choice list, and POST /choose sends the picked release to the download
|
||||
client. A URL grabs directly instead — see test_api_url.py."""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from server import actions, jobs as jobs_mod, mf
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_jobs():
|
||||
jobs_mod.JOBS.clear()
|
||||
yield
|
||||
jobs_mod.JOBS.clear()
|
||||
|
||||
|
||||
def _wait(client, auth, job_id, status, timeout=3.0):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
body = client.get(f"/jobs/{job_id}", headers=auth).json()
|
||||
if body["status"] == status:
|
||||
return body
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f"job {job_id} never reached {status}")
|
||||
|
||||
|
||||
def _release(guid="g1", seeders=24, quality="FLAC"):
|
||||
return {"guid": guid, "indexerId": 2, "title": f"Overgrown-Lay_Cold-{guid}",
|
||||
"size": 312 * 1024 * 1024, "seeders": seeders, "indexer": "Redacted",
|
||||
"quality": {"quality": {"name": quality}}}
|
||||
|
||||
|
||||
def _album_hit():
|
||||
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
|
||||
artist="Overgrown",
|
||||
payload={"album": {"title": "Lay Cold",
|
||||
"artist": {"artistName": "Overgrown"}}})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lidarr(monkeypatch):
|
||||
"""A Lidarr that already holds Overgrown and offers two releases."""
|
||||
state = {"grabbed": [], "releases": [_release("a", 90), _release("b", 10, "MP3-320")],
|
||||
"hits": [_album_hit()]}
|
||||
monkeypatch.setattr("server.app.mf.build_combined_hits",
|
||||
lambda q, limit, yt_first, lidarr_only, yt_only: state["hits"])
|
||||
monkeypatch.setattr(actions.mf, "ensure_album", lambda album, root, search_all, dry_run: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
|
||||
monkeypatch.setattr(actions.mf, "list_releases", lambda album_id, **kw: state["releases"])
|
||||
monkeypatch.setattr(actions.mf, "grab_release",
|
||||
lambda rel, dry_run=False: state["grabbed"].append(rel["guid"]) or True)
|
||||
return state
|
||||
|
||||
|
||||
# --- find ------------------------------------------------------------------
|
||||
def test_find_returns_a_job_immediately(client, auth, lidarr):
|
||||
r = client.post("/fetch", params={"q": "Overgrown - Lay Cold"}, headers=auth)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "queued"
|
||||
assert body["job_id"]
|
||||
assert "Searching indexers now." in body["message"]
|
||||
assert body["hit"]["album"] == "Lay Cold"
|
||||
assert "choices" not in body
|
||||
|
||||
|
||||
def test_find_parks_the_job_with_a_choice_list(client, auth, lidarr):
|
||||
job_id = client.post("/fetch", params={"q": "Overgrown - Lay Cold"},
|
||||
headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "awaiting_choice")
|
||||
assert "Which one?" in body["message"]
|
||||
labels = [c["label"] for c in body["choices"]]
|
||||
assert labels == ["FLAC, 312 MB, 90 seeders, Redacted",
|
||||
"MP3-320, 312 MB, 10 seeders, Redacted"]
|
||||
assert [c["n"] for c in body["choices"]] == [1, 2]
|
||||
# Shortcuts can only "Choose from List" plain strings, so each choice also
|
||||
# carries a numbered label the client can read the number back off.
|
||||
assert [c["pick_label"] for c in body["choices"]] == [
|
||||
"1. FLAC, 312 MB, 90 seeders, Redacted",
|
||||
"2. MP3-320, 312 MB, 10 seeders, Redacted"]
|
||||
assert lidarr["grabbed"] == [] # nothing sent until the user picks
|
||||
|
||||
|
||||
def test_find_choices_never_leak_the_raw_guid(client, auth, lidarr):
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "awaiting_choice")
|
||||
assert all("guid" not in c for c in body["choices"])
|
||||
assert "guid" not in str(body["result"])
|
||||
assert "pending" not in body["result"]
|
||||
|
||||
|
||||
def test_find_caps_the_list(client, auth, lidarr, monkeypatch):
|
||||
lidarr["releases"] = [_release(f"g{i}") for i in range(30)]
|
||||
monkeypatch.setattr(actions, "MAX_CHOICES", 5)
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "awaiting_choice")
|
||||
assert len(body["choices"]) == 5
|
||||
|
||||
|
||||
def test_find_auto_grabs_the_best_release_without_asking(client, auth, lidarr):
|
||||
job_id = client.post("/fetch", params={"q": "x", "auto": "true"},
|
||||
headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert lidarr["grabbed"] == ["a"]
|
||||
assert "download client" in body["message"]
|
||||
|
||||
|
||||
def test_find_reports_no_release_without_pretending_to_download(client, auth, lidarr):
|
||||
lidarr["releases"] = [] # Lidarr-only: no YouTube hit to fall back to
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert "no indexer has a release" in body["message"]
|
||||
|
||||
|
||||
def test_a_url_grabs_directly_and_never_asks(client, auth, lidarr, monkeypatch):
|
||||
""""If a URL, grab directly" — the choice list is for free text only."""
|
||||
monkeypatch.setattr("server.app.mf._is_direct_url", lambda url: False)
|
||||
monkeypatch.setattr("server.app.mf.resolve_link_hits",
|
||||
lambda url, limit: ("Overgrown - Lay Cold", lidarr["hits"]))
|
||||
job_id = client.post("/fetch", params={"q": "https://open.spotify.com/track/abc"},
|
||||
headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert lidarr["grabbed"] == ["a"]
|
||||
assert "download client" in body["message"]
|
||||
|
||||
|
||||
def test_youtube_source_skips_lidarr(client, auth, lidarr, monkeypatch):
|
||||
yt_hit = mf.Hit(source="youtube", kind="track", title="Lay Cold", artist="Overgrown",
|
||||
album="Lay Cold", payload={"videoId": "abc"})
|
||||
lidarr["hits"] = [yt_hit]
|
||||
monkeypatch.setattr(actions.mf, "act_youtube", lambda *a, **kw: True)
|
||||
job_id = client.post("/fetch", params={"q": "x", "source": "youtube"},
|
||||
headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert lidarr["grabbed"] == []
|
||||
assert body["message"] == "Finished downloading 'Lay Cold' by Overgrown."
|
||||
|
||||
|
||||
def test_find_adds_a_missing_artist_only(client, auth, lidarr, monkeypatch):
|
||||
added = []
|
||||
lidarr["hits"] = [mf.Hit(source="lidarr", kind="artist", title="Overgrown",
|
||||
artist="Overgrown",
|
||||
payload={"artist": {"artistName": "Overgrown"}})]
|
||||
monkeypatch.setattr(actions.mf, "ensure_artist", lambda meta, root, search_all, dry_run:
|
||||
(added.append(meta["artistName"]),
|
||||
{"artist": {"id": 9}, "state": "added", "name": "Overgrown"})[1])
|
||||
job_id = client.post("/fetch", params={"q": "Overgrown"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert added == ["Overgrown"]
|
||||
assert "Added Overgrown to Lidarr" in body["message"]
|
||||
assert lidarr["grabbed"] == []
|
||||
|
||||
|
||||
def test_no_hits_is_a_404_not_a_job(client, auth, lidarr):
|
||||
lidarr["hits"] = []
|
||||
r = client.post("/fetch", params={"q": "zzz"}, headers=auth)
|
||||
assert r.status_code == 404
|
||||
assert "zzz" in r.json()["message"]
|
||||
|
||||
|
||||
def test_failure_message_explains_why(client, auth, lidarr, monkeypatch):
|
||||
monkeypatch.setattr(actions.mf, "ensure_album", lambda album, root, search_all, dry_run: {
|
||||
"album": None, "album_id": None, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "missing"})
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
body = _wait(client, auth, job_id, "failed")
|
||||
assert "no album called 'Lay Cold'" in body["message"]
|
||||
|
||||
|
||||
def test_find_needs_the_api_key(client, lidarr):
|
||||
assert client.post("/fetch", params={"q": "x"}).status_code == 401
|
||||
|
||||
|
||||
# --- choose ----------------------------------------------------------------
|
||||
def _parked(client, auth, lidarr):
|
||||
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
|
||||
_wait(client, auth, job_id, "awaiting_choice")
|
||||
return job_id
|
||||
|
||||
|
||||
def test_choose_sends_the_picked_release(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
r = client.post("/choose", params={"job_id": job_id, "n": 2}, headers=auth)
|
||||
assert r.status_code == 200
|
||||
assert "Sending 'Lay Cold' by Overgrown" in r.json()["message"]
|
||||
body = _wait(client, auth, job_id, "done")
|
||||
assert lidarr["grabbed"] == ["b"]
|
||||
assert body["message"] == ("Sent 'Lay Cold' by Overgrown to the download client. "
|
||||
"MP3-320, 312 MB, 10 seeders, Redacted.")
|
||||
assert "choices" not in body
|
||||
|
||||
|
||||
def test_choose_twice_is_refused(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
|
||||
_wait(client, auth, job_id, "done")
|
||||
r = client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
|
||||
assert r.status_code == 409
|
||||
assert lidarr["grabbed"] == ["a"]
|
||||
|
||||
|
||||
def test_choose_out_of_range_is_refused(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
r = client.post("/choose", params={"job_id": job_id, "n": 9}, headers=auth)
|
||||
assert r.status_code == 409
|
||||
assert "between 1 and 2" in r.json()["message"]
|
||||
assert lidarr["grabbed"] == []
|
||||
|
||||
|
||||
def test_choose_unknown_job_is_404(client, auth):
|
||||
r = client.post("/choose", params={"job_id": "nope", "n": 1}, headers=auth)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_the_old_split_endpoints_are_gone(client, auth):
|
||||
assert client.post("/lidarr/find", params={"q": "x"}, headers=auth).status_code == 404
|
||||
assert client.post("/lidarr/choose", params={"job_id": "x", "n": 1},
|
||||
headers=auth).status_code == 404
|
||||
|
||||
|
||||
def test_choose_zero_is_rejected_by_validation(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
assert client.post("/choose", params={"job_id": job_id, "n": 0},
|
||||
headers=auth).status_code == 422
|
||||
|
||||
|
||||
def test_choose_needs_the_api_key(client, auth, lidarr):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
assert client.post("/choose", params={"job_id": job_id, "n": 1}).status_code == 401
|
||||
|
||||
|
||||
def test_a_parked_job_expires(client, auth, lidarr, monkeypatch):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
jobs_mod.JOBS[job_id].expires_at = time.time() - 1
|
||||
body = client.get(f"/jobs/{job_id}", headers=auth).json()
|
||||
assert body["status"] == "expired"
|
||||
assert "expired" in body["message"]
|
||||
r = client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
|
||||
assert r.status_code == 409
|
||||
assert lidarr["grabbed"] == []
|
||||
|
||||
|
||||
def test_choose_reports_a_lidarr_refusal(client, auth, lidarr, monkeypatch):
|
||||
job_id = _parked(client, auth, lidarr)
|
||||
monkeypatch.setattr(actions.mf, "grab_release", lambda rel, dry_run=False: False)
|
||||
client.post("/choose", params={"job_id": job_id, "n": 1}, headers=auth)
|
||||
body = _wait(client, auth, job_id, "failed")
|
||||
assert "refused the release" in body["message"]
|
||||
268
tests/test_lidarr_library.py
Normal file
268
tests/test_lidarr_library.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Library-aware Lidarr behaviour: never add a duplicate artist, never grab a
|
||||
random album, and describe a release in words a person understands."""
|
||||
import server.mf # noqa: F401 — loads musicfetch, registers musicfetch_core
|
||||
import musicfetch_core as mf
|
||||
|
||||
|
||||
JACKO = {"id": 3, "artistName": "Michael Jackson", "foreignArtistId": "mj-mbid"}
|
||||
|
||||
|
||||
def _no_add(*_a, **_kw):
|
||||
raise AssertionError("add_artist must not be called for an existing artist")
|
||||
|
||||
|
||||
# --- find_library_artist / ensure_artist -----------------------------------
|
||||
def test_find_library_artist_matches_name_case_insensitively(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
assert mf.find_library_artist(name="michael jackson") == JACKO
|
||||
|
||||
|
||||
def test_find_library_artist_matches_mbid_when_name_differs(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
assert mf.find_library_artist(name="Jacko", foreign_id="mj-mbid") == JACKO
|
||||
|
||||
|
||||
def test_find_library_artist_returns_none_when_absent(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
assert mf.find_library_artist(name="Overgrown") is None
|
||||
|
||||
|
||||
def test_ensure_artist_never_adds_a_duplicate(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "add_artist", _no_add)
|
||||
res = mf.ensure_artist({"artistName": "Michael Jackson"}, "/media/music", False, False)
|
||||
assert res["state"] == "existing"
|
||||
assert res["artist"] == JACKO
|
||||
assert res["name"] == "Michael Jackson"
|
||||
|
||||
|
||||
def test_ensure_artist_adds_when_missing(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [])
|
||||
monkeypatch.setattr(mf, "add_artist",
|
||||
lambda meta, root, search_all, dry_run: {"id": 9, "artistName": "Overgrown"})
|
||||
res = mf.ensure_artist({"artistName": "Overgrown", "foreignArtistId": "og"},
|
||||
"/media/music", False, False)
|
||||
assert res["state"] == "added"
|
||||
assert res["artist"]["id"] == 9
|
||||
|
||||
|
||||
def test_ensure_artist_reports_failure(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [])
|
||||
monkeypatch.setattr(mf, "add_artist", lambda *a, **kw: None)
|
||||
res = mf.ensure_artist({"artistName": "Overgrown"}, "/media/music", False, False)
|
||||
assert res["state"] == "failed"
|
||||
assert res["artist"] is None
|
||||
|
||||
|
||||
def test_act_lidarr_artist_does_not_add_existing(monkeypatch, capsys):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "add_artist", _no_add)
|
||||
hit = mf.Hit(source="lidarr", kind="artist", title="Michael Jackson",
|
||||
artist="Michael Jackson", payload={"artist": {"artistName": "Michael Jackson"}})
|
||||
assert mf.act_lidarr_artist(hit, "/media/music", False, False) is True
|
||||
assert "already in Lidarr" in capsys.readouterr().out
|
||||
|
||||
|
||||
# --- album matching --------------------------------------------------------
|
||||
LIB_ALBUMS = [
|
||||
{"id": 11, "title": "Off the Wall", "foreignAlbumId": "otw"},
|
||||
{"id": 12, "title": "Thriller (Deluxe Edition)", "foreignAlbumId": "thr-deluxe"},
|
||||
]
|
||||
|
||||
|
||||
def test_match_album_prefers_the_musicbrainz_id():
|
||||
got = mf._match_album(LIB_ALBUMS, {"title": "Something Else", "foreignAlbumId": "otw"})
|
||||
assert got["id"] == 11
|
||||
|
||||
|
||||
def test_match_album_matches_title_ignoring_case_and_punctuation():
|
||||
got = mf._match_album(LIB_ALBUMS, {"title": "off the wall!"})
|
||||
assert got["id"] == 11
|
||||
|
||||
|
||||
def test_match_album_ignores_an_edition_suffix():
|
||||
got = mf._match_album(LIB_ALBUMS, {"title": "Thriller"})
|
||||
assert got["id"] == 12
|
||||
|
||||
|
||||
def test_match_album_returns_none_instead_of_a_random_album():
|
||||
assert mf._match_album(LIB_ALBUMS, {"title": "Bad"}) is None
|
||||
|
||||
|
||||
def test_ensure_album_reports_missing_album_rather_than_guessing(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: LIB_ALBUMS)
|
||||
res = mf.ensure_album({"title": "Bad", "artist": {"artistName": "Michael Jackson"}},
|
||||
"/media/music", False, False)
|
||||
assert res["album"] is None
|
||||
assert res["album_state"] == "missing"
|
||||
assert res["artist_state"] == "existing"
|
||||
|
||||
|
||||
def test_ensure_album_monitors_an_unmonitored_album(monkeypatch):
|
||||
album = {"id": 11, "title": "Off the Wall", "monitored": False}
|
||||
puts = []
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: [album])
|
||||
monkeypatch.setattr(mf, "lidarr_put", lambda path, payload, **kw: puts.append(payload) or {})
|
||||
res = mf.ensure_album({"title": "Off the Wall", "artist": {"artistName": "Michael Jackson"}},
|
||||
"/media/music", False, False)
|
||||
assert res["album_state"] == "monitored"
|
||||
assert res["album_id"] == 11
|
||||
assert puts == [{"albumIds": [11], "monitored": True}]
|
||||
|
||||
|
||||
def test_ensure_album_leaves_a_monitored_album_alone(monkeypatch):
|
||||
album = {"id": 11, "title": "Off the Wall", "monitored": True}
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [JACKO])
|
||||
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: [album])
|
||||
monkeypatch.setattr(mf, "lidarr_put", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not re-monitor")))
|
||||
res = mf.ensure_album({"title": "Off the Wall", "artist": {"artistName": "Michael Jackson"}},
|
||||
"/media/music", False, False)
|
||||
assert res["album_state"] == "existing"
|
||||
|
||||
|
||||
# --- releases --------------------------------------------------------------
|
||||
def _release(**kw):
|
||||
base = {"guid": "g1", "indexerId": 2, "title": "Overgrown-Lay_Cold-WEB-2023",
|
||||
"size": 312 * 1024 * 1024, "seeders": 24, "indexer": "Redacted",
|
||||
"quality": {"quality": {"name": "FLAC"}}}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_release_label_is_readable():
|
||||
label = mf.release_label(_release())
|
||||
assert label == "FLAC, 312 MB, 24 seeders, Redacted"
|
||||
|
||||
|
||||
def test_release_label_uses_gigabytes_for_big_files():
|
||||
assert "2.0 GB" in mf.release_label(_release(size=2 * 1024 * 1024 * 1024))
|
||||
|
||||
|
||||
def test_release_label_flags_a_rejected_release():
|
||||
label = mf.release_label(_release(rejected=True, rejections=["Not a preferred word"]))
|
||||
assert "not recommended" in label
|
||||
assert "Not a preferred word" in label
|
||||
|
||||
|
||||
def test_release_label_falls_back_to_the_raw_title():
|
||||
assert mf.release_label({"title": "Weird.Release"}) == "Weird.Release"
|
||||
|
||||
|
||||
def test_sort_releases_puts_rejected_last_and_best_seeded_first():
|
||||
low = _release(guid="low", seeders=2)
|
||||
high = _release(guid="high", seeders=90)
|
||||
bad = _release(guid="bad", seeders=500, rejected=True)
|
||||
order = [r["guid"] for r in mf.sort_releases([low, bad, high])]
|
||||
assert order == ["high", "low", "bad"]
|
||||
|
||||
|
||||
def test_list_releases_skips_a_call_without_a_real_album_id():
|
||||
assert mf.list_releases(0) == []
|
||||
assert mf.list_releases(None) == []
|
||||
|
||||
|
||||
def test_grab_release_posts_the_exact_guid_and_indexer(monkeypatch):
|
||||
posted = {}
|
||||
monkeypatch.setattr(mf, "lidarr_post",
|
||||
lambda path, payload, **kw: posted.update(path=path, payload=payload) or {})
|
||||
assert mf.grab_release(_release()) is True
|
||||
assert posted["path"] == "/api/v1/release"
|
||||
assert posted["payload"] == {"guid": "g1", "indexerId": 2}
|
||||
|
||||
|
||||
def test_grab_release_refuses_an_incomplete_release(monkeypatch):
|
||||
monkeypatch.setattr(mf, "lidarr_post", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not post")))
|
||||
assert mf.grab_release({"title": "no guid"}) is False
|
||||
|
||||
|
||||
# --- album dispatch --------------------------------------------------------
|
||||
def _album_hit():
|
||||
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
|
||||
artist="Overgrown",
|
||||
payload={"album": {"title": "Lay Cold",
|
||||
"artist": {"artistName": "Overgrown"}}})
|
||||
|
||||
|
||||
def test_act_lidarr_album_grabs_the_best_release(monkeypatch, capsys):
|
||||
grabbed = {}
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release(guid="best")])
|
||||
monkeypatch.setattr(mf, "grab_release",
|
||||
lambda rel, dry_run=False: grabbed.update(guid=rel["guid"]) or True)
|
||||
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False) is True
|
||||
assert grabbed["guid"] == "best"
|
||||
out = capsys.readouterr().out
|
||||
assert "Overgrown is already in Lidarr." in out
|
||||
assert "download client" in out
|
||||
|
||||
|
||||
def test_act_lidarr_album_falls_through_when_no_indexer_has_it(monkeypatch, capsys):
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "added", "album_state": "monitored"})
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [])
|
||||
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False) is False
|
||||
out = capsys.readouterr().out
|
||||
assert "Added Overgrown to Lidarr." in out
|
||||
assert "Now monitoring" in out
|
||||
|
||||
|
||||
def test_act_lidarr_album_uses_the_picked_release(monkeypatch):
|
||||
grabbed = {}
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
|
||||
monkeypatch.setattr(mf, "list_releases",
|
||||
lambda album_id, **kw: [_release(guid="a"), _release(guid="b")])
|
||||
monkeypatch.setattr(mf, "pick_release", lambda releases: releases[1])
|
||||
monkeypatch.setattr(mf, "grab_release",
|
||||
lambda rel, dry_run=False: grabbed.update(guid=rel["guid"]) or True)
|
||||
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False,
|
||||
choose_release=True) is True
|
||||
assert grabbed["guid"] == "b"
|
||||
|
||||
|
||||
def test_act_lidarr_album_sends_nothing_when_the_user_quits(monkeypatch):
|
||||
monkeypatch.setattr(mf, "ensure_album", lambda *a, **kw: {
|
||||
"album": {"id": 5}, "album_id": 5, "artist_name": "Overgrown",
|
||||
"album_title": "Lay Cold", "artist_state": "existing", "album_state": "existing"})
|
||||
monkeypatch.setattr(mf, "list_releases", lambda album_id, **kw: [_release()])
|
||||
monkeypatch.setattr(mf, "pick_release", lambda releases: None)
|
||||
monkeypatch.setattr(mf, "grab_release", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not grab")))
|
||||
assert mf.act_lidarr_album(_album_hit(), "/media/music", False, False,
|
||||
choose_release=True) is True
|
||||
|
||||
|
||||
# --- guards ----------------------------------------------------------------
|
||||
def test_norm_title_keeps_accented_letters():
|
||||
assert mf._norm_title("Björk") == "björk"
|
||||
assert mf._norm_title("AC/DC") == "ac dc"
|
||||
|
||||
|
||||
def test_base_title_of_an_all_bracket_title_matches_nothing():
|
||||
assert mf._base_title("(Untitled)") == ""
|
||||
assert mf._match_album([{"id": 1, "title": "(Whatever)"}], {"title": "(Untitled)"}) is None
|
||||
|
||||
|
||||
def test_set_album_monitored_refuses_an_album_without_a_library_id(monkeypatch):
|
||||
monkeypatch.setattr(mf, "lidarr_put", lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("must not PUT")))
|
||||
assert mf.set_album_monitored({"title": "Lay Cold"}, False) is False
|
||||
|
||||
|
||||
def test_ensure_album_stops_when_lidarr_gives_no_artist_id(monkeypatch):
|
||||
monkeypatch.setattr(mf, "library_artists", lambda: [])
|
||||
monkeypatch.setattr(mf, "add_artist", lambda *a, **kw: {"artistName": "Overgrown"})
|
||||
monkeypatch.setattr(mf, "_artist_albums", lambda artist_id: (_ for _ in ()).throw(
|
||||
AssertionError("must not list every album")))
|
||||
res = mf.ensure_album({"title": "Lay Cold", "artist": {"artistName": "Overgrown"}},
|
||||
"/media/music", False, False)
|
||||
assert res["album"] is None
|
||||
assert res["album_state"] == "missing"
|
||||
120
tests/test_lidarr_messages.py
Normal file
120
tests/test_lidarr_messages.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""The spoken message must match what the server really did."""
|
||||
from server import actions, mf
|
||||
|
||||
|
||||
def _artist_hit(name="Michael Jackson"):
|
||||
return mf.Hit(source="lidarr", kind="artist", title=name, artist=name,
|
||||
payload={"artist": {"artistName": name}})
|
||||
|
||||
|
||||
def _album_hit():
|
||||
return mf.Hit(source="lidarr", kind="album", title="Lay Cold", album="Lay Cold",
|
||||
artist="Overgrown", payload={"album": {"title": "Lay Cold"}})
|
||||
|
||||
|
||||
def _yt_hit():
|
||||
return mf.Hit(source="youtube", kind="track", title="Lay Cold", artist="Overgrown",
|
||||
album="Lay Cold", payload={"videoId": "abc"})
|
||||
|
||||
|
||||
def test_artist_message_never_says_x_by_x():
|
||||
"""The old bug: "Finished downloading 'Michael Jackson' by Michael Jackson."."""
|
||||
hit = _artist_hit()
|
||||
for msg in (actions.started_message(hit),
|
||||
actions.done_message(hit, {"outcome": "artist_added"}),
|
||||
actions.done_message(hit, {"outcome": "artist_existing"}),
|
||||
actions.failed_message(hit)):
|
||||
assert "'Michael Jackson' by Michael Jackson" not in msg
|
||||
|
||||
|
||||
def test_artist_added_message_does_not_claim_a_download():
|
||||
msg = actions.done_message(_artist_hit(), {"outcome": "artist_added"})
|
||||
assert "Added Michael Jackson to Lidarr" in msg
|
||||
assert "Nothing was downloaded" in msg
|
||||
assert "Finished downloading" not in msg
|
||||
|
||||
|
||||
def test_artist_existing_message_says_nothing_was_added():
|
||||
msg = actions.done_message(_artist_hit(), {"outcome": "artist_existing"})
|
||||
assert "already in Lidarr" in msg
|
||||
assert "Nothing was added" in msg
|
||||
|
||||
|
||||
def test_album_grabbed_message_says_download_client():
|
||||
"""Falls back to the hit when the result does not name the album."""
|
||||
msg = actions.done_message(_album_hit(), {"outcome": "album_grabbed"})
|
||||
assert "Sent 'Lay Cold' by Overgrown to the download client." == msg
|
||||
|
||||
|
||||
def test_youtube_fallback_message_names_youtube():
|
||||
"""True whether no indexer had it or Lidarr refused the release."""
|
||||
msg = actions.done_message(_album_hit(), {"outcome": "youtube_fallback"})
|
||||
assert "Lidarr could not get 'Lay Cold' by Overgrown" in msg
|
||||
assert "YouTube Music" in msg
|
||||
|
||||
|
||||
def test_started_message_for_an_artist_does_not_say_downloading():
|
||||
assert actions.started_message(_artist_hit()) == "Looking up Michael Jackson in Lidarr."
|
||||
|
||||
|
||||
def test_started_message_for_a_lidarr_album_says_searching_indexers():
|
||||
msg = actions.started_message(_album_hit())
|
||||
assert "Searching indexers now." in msg
|
||||
|
||||
|
||||
def test_youtube_messages_are_unchanged():
|
||||
assert actions.started_message(_yt_hit()).startswith("Found 'Lay Cold' by Overgrown on YouTube Music")
|
||||
assert actions.done_message(_yt_hit()) == "Finished downloading 'Lay Cold' by Overgrown."
|
||||
|
||||
|
||||
def test_choice_message_asks_which_one():
|
||||
msg = actions.choice_message(_album_hit(), {
|
||||
"artist": "Overgrown", "album": "Lay Cold", "artist_state": "existing",
|
||||
"album_state": "existing", "choices": [{"n": 1}, {"n": 2}]})
|
||||
assert "Overgrown is already in Lidarr." in msg
|
||||
assert "I found 2 releases for 'Lay Cold' by Overgrown. Which one?" in msg
|
||||
|
||||
|
||||
def test_choice_message_mentions_a_new_artist_and_new_monitor():
|
||||
msg = actions.choice_message(_album_hit(), {
|
||||
"artist": "Overgrown", "album": "Lay Cold", "artist_state": "added",
|
||||
"album_state": "monitored", "choices": [{"n": 1}]})
|
||||
assert "Added Overgrown to Lidarr." in msg
|
||||
assert "Now monitoring 'Lay Cold'." in msg
|
||||
assert "I found 1 release for" in msg
|
||||
|
||||
|
||||
def test_choice_message_falls_back_to_the_hit():
|
||||
msg = actions.choice_message(_album_hit(), {"choices": [{"n": 1}]})
|
||||
assert "'Lay Cold' by Overgrown" in msg
|
||||
|
||||
|
||||
def test_no_release_message_is_honest():
|
||||
msg = actions.done_message(_album_hit(), {
|
||||
"outcome": "no_release", "artist": "Overgrown", "album": "Lay Cold",
|
||||
"artist_state": "added", "album_state": "monitored"})
|
||||
assert "no indexer has a release" in msg
|
||||
assert "download" not in msg.lower()
|
||||
|
||||
|
||||
def test_album_grabbed_message_names_the_release_when_known():
|
||||
msg = actions.done_message(_album_hit(), {
|
||||
"outcome": "album_grabbed", "artist": "Overgrown", "album": "Lay Cold",
|
||||
"release": "FLAC, 312 MB, 24 seeders, Redacted"})
|
||||
assert msg == ("Sent 'Lay Cold' by Overgrown to the download client. "
|
||||
"FLAC, 312 MB, 24 seeders, Redacted.")
|
||||
|
||||
|
||||
def test_fail_text_speaks_a_fetch_error_and_hides_anything_else():
|
||||
hit = _album_hit()
|
||||
assert actions.fail_text(hit, actions.FetchError("Overgrown is in Lidarr, but ...")) \
|
||||
== "Overgrown is in Lidarr, but ..."
|
||||
assert actions.fail_text(hit, OSError("[Errno 2] No such file: 'yt-dlp'")) \
|
||||
== "Failed to fetch 'Lay Cold' by Overgrown."
|
||||
|
||||
|
||||
def test_grab_message_names_the_release():
|
||||
msg = actions.grab_message({"artist": "Overgrown", "album": "Lay Cold",
|
||||
"release": "FLAC, 312 MB, 24 seeders, Redacted"})
|
||||
assert msg == ("Sent 'Lay Cold' by Overgrown to the download client. "
|
||||
"FLAC, 312 MB, 24 seeders, Redacted.")
|
||||
Reference in New Issue
Block a user