Compare commits

...
84 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 b9684e6d12 fix: a group of one recording in several slots is settled by getting the album again, not merged (#6184)
release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m45s
release / android (push) Successful in 5m9s
release / integration (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicates review offered only Merge for a wrong-slot group. A merge keeps
one copy's title whether or not it is the audio's, and neither sets the release
aside nor wants the missing song back.

For class mismatch the page now asks which song the recording is. "Get it
again" (the operator can't tell) removes every copy; "Keep <title>" keeps that
copy and removes the ones under other titles. Either way it runs the resolver's
own path: the release the files came from is set aside, the removed copies'
tracks stay marked missing and emptied, and the album is wanted again.

- library.SettleWrongSlot, POST /api/admin/library/duplicates/{id}/refetch
- ListDuplicateGroupsForResolve takes an optional group_id
- removeWrongSlots takes the acting operator (manual: not automatic, actor in
  the audit row) and removes files through removeTrackFileOnDisk, so a
  read-only library answers library_not_writable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:47:43 -04:00
bvandeusenandClaude Opus 5.5 5bcbc80555 fix: the taste arm round-robins across taste artists, and tag ties follow the order seed (#3880)
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m22s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
Measured on the operator's library (26,005 tracks, pool scale 2.28): the
arms the library scale grows all have far more rows than it asks for, and
two arms collapsed whatever their limits were.

- taste_overlap ordered by weight then track id, so the strongest taste
  artist's catalogue filled it: 20 tracks of saib. (197 tracks) out of 174
  taste artists, the same lowest-id tracks every time. It now takes one
  track per taste artist, strongest first, md5-seeded, as similar_artists
  does since #5297.
- tag_overlap broke ties in no order. A one-tag seed ties 7,804 tracks,
  and the LIMIT took whichever Postgres reached first, which the order
  seed did not control. Ties now follow md5(id || seed).
- library_scale.go records the per-arm measurement beside the reference
  size it leaves as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:12:58 -04:00
bvandeusenandClaude Opus 5.5 484a81f779 fix: a release its download client will not let Minstrel follow is held back, not taken every pass (#6176)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m5s
release / android (push) Successful in 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 22s
release / Verify release artifacts (tag releases only) (push) Skipped
The Black Keys' Ohio Players was grabbed 40 times in a day. FabledExchange
already held the torrent under another category from before Minstrel took
the downloads over: the add answered "duplicate", which counted as added,
and Exchange's listing, filtered to Minstrel's category, never showed it.
Half an hour later the grab ended gone, and the next pass took it again.

- A duplicate add that the client does not list for Minstrel is not a
  grab: the release is held back for a day with the client's own reason
  (already downloading X (category)), and the search moves on. Forced,
  it is refused with that reason.
- A release that ends gone a second time within a week is held back for
  a day, whatever the cause.
- release_blocks gains the reason 'gone' (migration 0098), shown as
  "Held back: its download client lost track of it".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:31:57 -04:00
bvandeusenandClaude Opus 5.5 cb1f619a21 fix: a track shorter than the fingerprint span keeps its print (#6178)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m14s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m18s
release / Verify release artifacts (tag releases only) (push) Skipped
The image's fpcalc 1.5.1 exits 3 with "Error decoding audio frame (End
of file)" whenever it reaches the end of a file before -length, at any
length, so every track under two minutes failed both the AcoustID lookup
print and the duplicate-detection print: 471 lookups and all 1,979
present short tracks' chromaprints.

- runFingerprintTool returns the tool's stdout beside a toolExitError.
- fpcalcReadToEnd keeps a partial exit whose stderr is the end of file
  and whose declared DURATION is within the span asked for. A damaged
  file that stops early declares more than it read, and still fails.
- Migration 0097 clears the stored failures so the lookup worker and
  the fingerprint backfill take those tracks again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:01:23 -04:00
bvandeusenandClaude Opus 5.5 e44025435e fix: ListenBrainz similarity is stored relative to the seed's best, and the candidate arms keep tier order (#6177)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875
for artists), and they reached a scorer that reads similarity as 0 to 1.
One edge added 64 to 5,800 points under Songs-like, so likes, skips and
recency stopped mattering for any candidate with an edge.

- ResolveListenBrainzTrackEdges divides each edge by the seed's best
  score in its whole cached answer. The cache keeps the raw answer.
- The worker stores matched and unmatched artist rows over the seed's
  top artist.
- Migration 0096 rescales the rows already stored.
- The candidate arms hold sim_score bands so tier 1 stays above tier 2:
  lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3.
  Otherwise a seed's weaker LB edges fell below a shared genre tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:56:26 -04:00
bvandeusenandClaude Opus 5.5 46e1dd6c9c test(android): run the sync pass in the test body, not through init's background collector (#5654)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m5s
release / android (push) Successful in 5m10s
release / Build signed APK (releases and dev) (push) Successful in 5m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
advanceUntilIdle does not count backgroundScope work as pending, so the
pass init would start never ran. The network state is Offline so
construction starts none, and each test calls syncSafe itself and asserts
lastSyncError stays null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:03:51 -04:00
bvandeusenandClaude Opus 5.5 77f06b60f7 test(android): name the 204 body type so Response.success resolves (#5654)
release / govulncheck (push) Successful in 19s
release / web (push) Successful in 1m32s
release / android (push) Failing after 4m13s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m15s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:57:29 -04:00
bvandeusenandClaude Opus 5.5 42132810b0 fix(android): sync pulls every page of the change log, and a 404 drops the cached row (#5654)
release / govulncheck (push) Successful in 32s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / android (push) Failing after 3m58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / Build signed APK (releases and dev) (push) Canceled after 4m39s
The server returns at most 1000 changes per /api/library/sync call and
expects clients to loop with the returned cursor. SyncController took one
page per trigger (cold start, reconnect), so the cache fell behind a log
that a full re-read grows by ~26,000 rows and, past the 30-day compaction,
reset through 410 to cursor 0 without ever reaching the merge deletes of
2026-10-09/10. The freshness sweep then re-asked for those tracks every
30 minutes: the same ~100 404s on every pass.

- SyncController pages until 204, storing the cursor after each page, and
  stops on a cursor that does not advance.
- MetadataProvider drops a cached album, artist or track the server
  answers 404 for, so rows orphaned before this fix stop being refetched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:52:54 -04:00
bvandeusenandClaude Opus 5.5 210f884b2d fix: a file tagged with no release group joins the same-named album its folder holds
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m31s
release / integration (push) Successful in 4m58s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Under e45561e2 a file with no group tag took the oldest same-named album row,
which left three of Weezer's 1994 bonus tracks on the 2016 album. The lookup
now ranks same-named rows by how many tracks they hold from the file's
folder; tagReadVersion 8 re-places the existing ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:44:45 -04:00
bvandeusenandClaude Opus 5.5 e45561e282 fix: same-titled albums of two release groups are two albums, and a missing track with no disc is on disc 1
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 5m0s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
The scanner found an album by artist and title alone, so Weezer's six
self-titled albums (1994–2026) were one 118-track row and their merges,
wants and upgrades counted them as one. A same-named row whose release group
is known and differs from the file's is now another album; tagReadVersion 7
re-reads the library so existing tracks move to their own rows.

WantHoldings compared discs with IS NOT DISTINCT FROM, so a missing track 6
with no disc was never covered by a present disc 1 track 6. Air's Love 2
stayed wanted and was downloaded again for nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:13:58 -04:00
bvandeusenandClaude Opus 5.5 f32abc974f fix: a duplicate fold never deletes a lossless copy for a lossy one, nor folds two versions (M498)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m31s
release / android (push) Successful in 4m58s
release / integration (push) Successful in 5m0s
release / Build signed APK (releases and dev) (push) Successful in 5m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
The first album-copy fold pass on 2026-10-20 removed five FLACs (Måneskin
Rush!, Weezer 2016) in favour of mp3s whose only edge was a recording MBID,
and an explicit "Suite-Pee" for its clean version. ProposeSurvivor now ranks
lossless before tag fit; tag fit still outranks file size, which is what the
Humanz rips needed. The fold keys copies by their version words (title and
file name), so a clean, live or instrumental copy holding the same title tag
is linked, not folded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:36:55 -04:00
bvandeusenandClaude Opus 5.5 16a152b862 test: the album-copy fold test makes each album once, as UpsertAlbum folds only by MBID (#5401)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m38s
release / android (push) Successful in 5m8s
release / integration (push) Successful in 5m8s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:01:16 -04:00
bvandeusenandClaude Opus 5.5 3ebd35e51d fix: a slot the resolver emptied folds only into a copy of its own song (M498)
release / web (push) Successful in 1m28s
release / go (push) Successful in 1m36s
release / android (push) Successful in 5m2s
release / govulncheck (push) Successful in 16s
release / integration (push) Failing after 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
On 2026-10-20 the emptied "Open the Gateway" slot of Auvic's Speed Demons
was folded by title into another file tagged "Open the Gateway" that holds
Onward (244 s against the song's 254 s), so the album was never wanted
back. A wrong-slot removal now stamps tracks.emptied_at (migration 0095)
when the album holds no copy of the song as long as its MusicBrainz slot,
and missing-pair folding pairs such a row by title only with a track added
since: a copy fetched again. A slot whose song the album does hold (VOID
DLC's Weeper beside Void's) folds into it as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:55:23 -04:00
bvandeusenandClaude Opus 5.5 09e74a57a2 feat: an album holding a song twice beside its single keeps one copy (#5401)
A cross-release group was only ever linked, never merged, so an album that
held the song twice among the releases kept both: Humanz has Saturnz Barz,
Andromeda, We Got the Power and Garage Palace as its FLAC and a YouTube mp3,
each beside the single. The resolver now merges an album's extra copies
into its best (the one that is not a video rip, else ProposeSurvivor:
lossless first) with ReplaceTrack, history and all, then links what is
left as one song.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:55:23 -04:00
bvandeusenandClaude Opus 5.5 80ecc2f7e7 fix: imported FLAC, Ogg and Opus files keep their track and disc numbers (#6171)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
The importer wrote track=N/M for every format, which ffmpeg stores in a
Vorbis comment as TRACKNUMBER=3/20; dhowden/tag reads a Vorbis TRACKNUMBER
with a bare Atoi, so it came back 0. All 245 tracks imported since
2026-10-10 (Humanz, Plastic Beach, the Gorillaz super deluxe and twelve
more albums) have no track or disc number, and their album pages have no
order.

- The importer writes a Vorbis track and disc number alone, with
  TRACKTOTAL and DISCTOTAL, as Picard does. ID3 and MP4 keep N/M.
- ReadTrackAndDisc takes the number before a slash when the Vorbis reader
  returns 0, for the scanner and for the import's own check of a download.
- tagReadVersion 6 re-reads the files already held (tag reads only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:49:44 -04:00
bvandeusenandClaude Opus 5.5 42e48efb79 fix: the duplicate resolver waits for a running scan, so a copy it removes is not marked present again (#6169)
release / govulncheck (push) Successful in 13s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 5m10s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
At boot the scan lists the library, the resolver removes wrong-slot copies
and marks their tracks missing, then the scan reconciles from its list and
marks them present: 7 of the 13 removed on 2026-10-20 15:00 read as
present with no file, and would not be wanted back until a later scan. A
pass now waits while a scan younger than StuckScanThreshold is in flight
and tries again a minute later; a stuck run does not hold it up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:04:46 -04:00
bvandeusenandClaude Opus 5.5 3a482bd715 fix: AcoustID decides any one-album mismatch the lengths cannot, not only copies each cut to its slot (#6169)
release / govulncheck (push) Successful in 13s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m3s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
The first pass on the operator's library held 16 groups. In several, every
copy's own lookup names one recording (Wombats Record Shop/Tormentor,
Alchemist (2)/Prelude, Monday Chill 8/9, 404sessions), but one copy sits 3-10s
from its slot, so the every-copy-fits precondition kept AcoustID out. Now:
copies whose lookups all agree are that recording, whatever their
fingerprints read; copies of the same audio are asked about as before; and
the track list places the recording in one copy's slot, or none. Copies
filed as one song are not settled here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:52:42 -04:00
bvandeusenandClaude Opus 5.5 18d31e9cdd feat: one recording cut to several songs' lengths is named by AcoustID; the copy in its slot stays, the rest go, and the release is set aside (#6169)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m31s
release / android (push) Successful in 5m1s
release / integration (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
Savant's Mortals held one recording as 16 of its 50 tracks, each trimmed to
its own slot's MusicBrainz length, so the lengths could not say which slot
was its own and every group waited for the operator. When a one-album
mismatch is the same audio (identical, or fingerprints within 0.02) and
every copy fits its own slot, the resolver asks what the recording is: the
copies' own AcoustID lookups, or a fresh lookup of one copy (five a pass).
The track list names each slot's recording; the copy in that slot is kept
and the others removed, or every copy when the recording is in none of
their slots or AcoustID knows none. AcoustID unreachable or answers in
several copies' slots: the group waits.

Whenever wrong-slot copies are removed, the download the files were
imported from is set aside, so the album fetched again is not the same
files. Albums imported before Minstrel downloaded anything have none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:33:55 -04:00
bvandeusenandClaude Opus 5.5 e3eddab80f feat: the duplicates review says what each match rests on and plays every copy (#6168)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m34s
release / android (push) Successful in 4m58s
release / integration (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Each group now states its evidence: identical audio data, or the share of
audio fingerprints that agree. Copies titled as different songs in one
acoustic group say so and ask the reviewer to listen, and each copy has a
play button so they can.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:28:29 -04:00
bvandeusenandClaude Opus 5.5 460e78c955 fix: an album with missing tracks is wanted back by its release group, not only when it carries a release MBID (#6167)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m32s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
The release id was only ever used to find the group. 238 albums matched to a
group carried none, so their losses, five of the resolver's wrong-slot
removals among them, were never fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:21:06 -04:00
bvandeusenandClaude Opus 5.5 39029b5cd9 fix: clearing a wrong slot's MBID clears its source too, as tracks_mbid_source_pair requires (M498)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m37s
release / android (push) Successful in 5m1s
release / integration (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:47:02 -04:00
bvandeusenandClaude Opus 5.5 8daa3d07b5 fix: a slot emptied of another song's file drops that file's recording MBID, so missing-pair folding cannot fold it into the copy kept (M498)
release / govulncheck (push) Successful in 24s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 5m1s
release / android (push) Successful in 5m2s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
On 2026-10-20 twelve of the 24 slots the resolver emptied were folded by MBID
into the kept copy, so their albums were never wanted back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:39:17 -04:00
bvandeusenandClaude Opus 5.5 304caaee02 fix: the AcoustID rate-limit test bounds arrivals by the service's 3 a second, not the client's send spacing
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
Run 9543 saw two arrivals 389ms apart though the client sent them 400ms apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:29:00 -04:00
bvandeusenandClaude Opus 5.5 a3d9effca3 feat: when no slot an album gives one recording is its own, every copy is removed and the album fetched again (M498)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m28s
release / go (push) Failing after 1m31s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m37s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Shnabubula's whole album, one 45-minute file, was filed as both Fading Light
and Looking for Answers. Neither slot fits, so wrongSlots had nothing to keep
and held the group. Every copy now goes when each is clearly another song's;
a copy near its slot still holds the group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:21:43 -04:00
bvandeusenandClaude Opus 5.5 c8e855ae4a feat: the duplicate resolver fetches a missing album track list from MusicBrainz to decide a wrong slot, five albums a pass (M498)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m33s
release / integration (push) Successful in 5m0s
release / android (push) Successful in 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
20 of the 34 groups left for review had no cached track list for their album:
the catalogue only reads one when the wanted list or the importer needs it.
The resolver now asks catalogue.RefreshReleaseGroup for an album it must
decide and the catalogue has never read, through the shared MusicBrainz pacer,
at most five a pass (hourly). An unreachable MusicBrainz stops the pass's
fetching; a group read and found empty is not asked about again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:02:53 -04:00
bvandeusenandClaude Opus 5.5 c5868c00cc feat: a version named after a dash counts as a version, and a song held twice beside a wrong slot no longer blocks it (M498)
release / govulncheck (push) Successful in 25s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m31s
release / android (push) Successful in 4m51s
release / integration (push) Successful in 4m53s
release / Build signed APK (releases and dev) (push) Successful in 5m0s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 17s
release / Verify release artifacts (tag releases only) (push) Skipped
- songKey drops a dashed version suffix (" - Instrumental Version", " - Radio
  Edit", live, remix, mix, acoustic, demo, reprise, rework, VIP), so Andora's
  "Goodbye Yesterday - Instrumental Version" is distinct, not a wrong slot.
- wrongSlots accepts several fitting copies when they share one title
  (Killjoy twice beside Dreamland): the other slot's copy is removed, and the
  doubled song is left for the next sweep to find as a pair the resolver merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:24:47 -04:00
bvandeusenandClaude Opus 5.5 80c711ac9d fix: a wrong-slot removal reads without a counted noun (M498)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m28s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m9s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:31:11 -04:00
bvandeusenandClaude Opus 5.5 0f58ebcd6c feat: duplicates settle themselves: retitled copies across releases link, alike versions on one album are dismissed, and a file in another song's slot is removed so the album is fetched again (M498 #6159-#6162)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.

- The headline and the Duplicates count leave out cross-release groups, which
  the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
  whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
  byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
  album's cached MusicBrainz track list decides. When exactly one slot's song
  is as long as the file and every other slot's song is clearly not, the other
  copies are removed and their tracks marked missing, so the reacquisition
  sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
  only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
  merges.

Migration 0094 adds 'distinct' to the class CHECK (rule 36).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:22:38 -04:00
bvandeusenandClaude Opus 5.5 8538f0dc68 fix: the playback-error test matches the prefixed username, and announcementFor keeps detekt's two returns (M542)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m8s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:53:13 -04:00
bvandeusenandClaude Opus 5.5 854c307834 feat: notifications say what changed and alert once: a grouped notice names the tracks, grows without re-alerting, and a phone pile lists its titles (M542 #6146-#6151)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m33s
release / android (push) Failing after 1m42s
release / integration (push) Failing after 4m42s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- An update to a grouped notice keeps its arrival time and email state, so the
  phone and the email no longer go off again each time it grows.
- Notices carry the names of what changed (newest ten), shown as three and
  "and N more": tracks still missing after the scan's move/replacement fold,
  duplicate groups new to a sweep, songs merged automatically, and the track
  and listener a playback error was for.
- Duplicates found counts only the groups new to that sweep; playback errors
  count reports, not the unresolved total.
- A scan failure says what stopped and links to Admin > Library.
- Android: a pile of more than three alerts lists the newest five titles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:44:19 -04:00
bvandeusenandClaude Opus 5.5 2365514e5b test: TestPlanRoots looks for the empty-list refusal as the message words it (#6129)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m22s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:40:54 -04:00
bvandeusenandClaude Opus 5.5 edcb3a57ef feat: the library's folders are set in Admin › Library, and changing a folder moves its tracks with it, keeping their plays, likes and playlists (#6129)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:37:03 -04:00
bvandeusenandClaude Opus 5.5 453a977e48 refactor: the admin and user Requests pages draw a request with one RequestRow, and the kind chip is one KindPill there and on Hidden tracks (#5651)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:20:53 -04:00
bvandeusenandClaude Opus 5.5 db15713798 refactor: the server says a count one way, words.Count, for notifications, a wanted match's reason and the duplicate resolver's activity line (M505 #5643)
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m35s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m41s
release / android (push) Successful in 6m7s
release / Build signed APK (releases and dev) (push) Successful in 6m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:18:50 -04:00
bvandeusenandClaude Opus 5.5 9e24b2b4fc refactor: every count in the web app is said by one plural(), with the viewer's thousands separator; a source guard names any hand-written copy (M505 #5642)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:18:04 -04:00
bvandeusenandClaude Opus 5.5 af2d4f9859 refactor: a running download is worded the same on the admin Overview as on the wanted list, from one liveDownloadLine (M505 #5641)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m29s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m21s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:15:21 -04:00
bvandeusenandClaude Opus 5.5 622f248ea2 refactor: track lengths and file sizes come from the shared formatters on Missing files, Duplicates, a want's Releases and the app download; an hour-long track reads 1:01:05, not 61:05 (M505 #5639 #5640)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:14:01 -04:00
bvandeusenandClaude Opus 5.5 9e7d50b38e fix: FLAC, Ogg and Opus tracks carry their MusicBrainz recording id from the tag: one reader in library gives the scanner, the backfills and the importer the same ids, and the next scan re-reads the library (#5637)
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m50s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m50s
release / Build signed APK (releases and dev) (push) Successful in 6m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:55:25 -04:00
bvandeusenandClaude Opus 5.5 c1305a547d feat: the admin Overview shows who is listening, downloads, the library and its disk, and the server's version, uptime and recent errors; a download waiting on you, a client not answering or a full disk count in its verdict (M504 #5627 #5628 #5629 #5630)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The version moves to internal/buildinfo, so /rest/ping reports the build
instead of "dev".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:51:41 -04:00
bvandeusenandClaude Opus 5.5 ead0abf0cf feat: one Missing files page under Collection: folders gone, each with its album's want, last search or download, and Search, Releases and Remove; an album held before its files went is no longer shown as held again (M504 #5626)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 6m2s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:44:33 -04:00
bvandeusenandClaude Opus 5.5 38b2325d2d feat: Reports, Duplicates and Suspect sources live under Collection, with their counts on its rail and a "To look at" line naming the ones waiting; notices link there (M504 #5625)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:35:59 -04:00
bvandeusenandClaude Opus 5.5 12725e71b0 fix: the reports test name keeps its apostrophe inside double quotes, and the merge API test sends no unmonitor (M502 #5525)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m12s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:55:55 -04:00
bvandeusenandClaude Opus 5.5 3a16732883 feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:48:20 -04:00
bvandeusenandClaude Opus 5.5 c54792c51e feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's
  cutoff, note #5527; "" is off), an "Upgrade until" row under Searching.
- The wanted worker judges held albums hourly: tier per format from size per
  second (the scanner records no bitrate), the album's worst. Below the
  cutoff it is wanted for quality; a video rip among its tracks always.
- The schedule searches upgrades weekly, after open wants; a release no
  better than the copy held is refused ("not_better").
- The import matches each file to the track it would replace (recording,
  then disc/track) and replaces it when it is a rip or the file is a better
  tier: over the same path in one rename, or filed beside it and the old
  track folded in (library.ReplaceTrack: file first, then foldTrackInto).
  Nothing better: the release is set aside.
- Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a
  clean copy was looked for instead of Lidarr's search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:59 -04:00
bvandeusenandClaude Opus 5.5 a5ad301c94 feat: Users, Diagnostics and Tuning sit on the admin layout like the rest: debug mode is a row action on Users, every admin load error has Try again, and figures carry their scale (M503 #5608)
release / android (push) Successful in 5m24s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
- Users: a one-line count of accounts, admins and open invites; Turn on/off debug mode in each row's actions, and a "debug on" badge that opens that account's events
- Diagnostics: no title or paragraph of its own; devices named by account, OS, app version and last seen, not client id; opens on ?account=
- Tuning: each profile, the taste build and Discover are row groups with one-line hints; the sparkline has a 0–100% axis
- Requests, Duplicates, Missing files and Suspect sources: load errors offer Try again
- Library: cover art reads "N of M albums have art"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:52:51 -04:00
bvandeusenandClaude Opus 5.5 0049b64ff1 fix: the AcoustID and Library tests expect the new copy and one folder per line (M503 #5607)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m43s
release / android (push) Successful in 5m19s
release / integration (push) Successful in 5m22s
release / Build signed APK (releases and dev) (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:30:20 -04:00
bvandeusenandClaude Opus 5.5 7f913e3e70 feat: the admin settings cards read as menus: each a titled group of rows with one short line, status first, failures in words (M503 #5607)
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m27s
release / go (push) Successful in 1m44s
release / android (push) Successful in 5m13s
release / integration (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Indexers and clients, searching, missing files, Lidarr's neighbours, AcoustID, fingerprinting, loudness, outgoing email's schedule, public address and proxies move from paragraph cards to SettingsGroup and SettingRow. Switches are role="switch". Also fixes the two type errors run 9023 found: the Lidarr test result is narrowed before its lists are read, and the tag-source save mock carries version_bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:25:46 -04:00
bvandeusenandClaude Opus 5.5 7e1cfb8d08 feat: Integrations splits into Library, Acquisition, Metadata sources and Email & network, each a page on the admin rail; the Overview keeps only what needs you (M503 #5607)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates)
- Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr
- Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair
- Email & network: outgoing email, the notification schedule, public address, proxies
- Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes
- Duplicates and Missing files say what their setting is and link to where it is set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:21:48 -04:00
bvandeusenandClaude Opus 5.5 0cc4bc686a fix: a report's details keep the space before a note, and the Reports menu test opens the row it means (M503 #5606)
release / govulncheck (push) Successful in 19s
release / web (push) Successful in 1m38s
release / go (push) Successful in 1m49s
release / android (push) Successful in 5m22s
release / integration (push) Successful in 5m27s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:57:02 -04:00
bvandeusenandClaude Opus 5.5 89a2653995 feat: Admin › Reports is one queue of broken tracks, whether a listener flagged it or it failed to play, one row per track with one set of actions (M503 #5606)
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m18s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- /admin/reports folds the quarantine queue and the unresolved playback
  errors into one row per track, newest first: who flagged it and why,
  what failed and for whom, the file, with Resolve (clears the reports and
  marks the errors fixed), Play, Copy details, Delete file and, for an album
  Lidarr holds, Delete via Lidarr.
- One delete path: Delete file removes the track, and its reports and
  errors go with the row.
- A Handled tab shows what was done: reports cleared, files deleted,
  errors marked fixed or ignored.
- The summary counts reports by track across both sources, so the rail,
  the Overview and the page agree.
- /admin/quarantine and /admin/playback-errors redirect to Reports: the
  notifications and the Android app link to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:53:58 -04:00
bvandeusenandClaude Opus 5.5 864eb45580 feat: Settings uses the width of a wide screen: groups sit in two columns, each narrow enough that a row's control stays near its name (M503 #5595)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The content was capped at max-w-3xl, so a wide window showed one narrow
strip and a lot of empty space. SettingsColumns lays a page's groups out as
one column on a phone or narrow window and two side by side from xl up;
the page cap moves to 100rem so the columns don't stretch on a very wide
screen.

- Account: Android app, Profile and Password, with Signed-in devices beside.
- Playback: "Between tracks" (crossfade) beside "Volume leveling".
- Connections: ListenBrainz beside Subsonic apps.
- Appearance, Notifications, About: one column's width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:28:06 -04:00
bvandeusenandClaude Opus 5.5 ace7e22e6a feat: Admin is a left rail with how many wait on you in each queue, and the Overview opens with a verdict, the queues that need you and since when, then the clear ones on one line (M503 #5605)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
- GET /api/admin/summary: every admin queue (requests, reported tracks,
  playback errors, duplicates, suspect sources, missing files) with its
  count and the age of its oldest item, in one query.
- The admin layout drops the 11-tab strip for SectionRail, as Settings and
  the Collection have, with each queue's count as its badge; the counts
  refresh on each admin navigation and every 30s.
- The Overview leads with "Nothing needs you." or "N things need you.",
  lists each waiting queue with "oldest 3d ago" and a link to it, counts a
  failed library scan as a problem, and names the empty queues on one line.
  The request and quarantine previews and the stat tiles are gone; those
  queues are worked on their own pages. A failed summary says so with Try
  again instead of reading as all-clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:58:13 -04:00
bvandeusenandClaude Opus 5.5 7c89e59536 fix: admin bugs from the audit: a playback-error delete no longer reports failure, a failed Overview load no longer reads as all-clear, and email settings can't be saved over with defaults (M503 #5604)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / android (push) Successful in 6m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
- Playback errors: Delete file stops resolving the error row the track
  deletion already took with it (ON DELETE CASCADE), which 404'd into
  "Delete failed".
- Overview: a queue that fails to load says so with Try again, and its tile
  shows a dash, not 0. Delete via Lidarr shows only for an album Lidarr
  holds while Lidarr is connected. An approve refreshes the Collection.
- Quarantine: Delete via Lidarr is in the row menu, so a phone reaches it;
  "1 report", not "1 reports".
- Duplicates: the Keep badge follows the copy picked.
- Integrations: provider save failures and load errors are said, not
  swallowed or shown as "No providers registered"; the Lidarr test catches
  a failed call; SMTP fills once and Save stays off until it has loaded.
  Lidarr's blurb says what it is still used for.
- Requests: release profiles point at Collection › Following.
- Library: an empty library sends an admin to Admin to run a scan, and
  tells anyone else the admin scans it, instead of opening Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:40:11 -04:00
bvandeusenandClaude Opus 5.5 c94c09aeae feat: Settings is one page per section down a left rail, Account first, every setting a row; the profile form no longer saves blanks over your name and email (M503 #5595 #5596 #5597)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
- /settings lands on Account: the Android app, profile, password, signed-in
  devices. Then Playback, Appearance, Notifications, Connections, About.
- Each setting is a row (name, one short line, control at the end) in a
  capped-width column; one-of-N choices are a shared Segmented control.
- Collection and Settings share one SectionRail.
- Connections leads ListenBrainz with a status line: not connected,
  connected and not sending, or sending with the last scrobble's age.
- Notifications prints its column labels once; the digest email's
  settings link points at /settings/notifications.
- #5596: the profile form now loads what is stored and sends only the
  fields that changed. It used to start blank, and the server reads a
  blank as "clear", so one Save wiped the display name and email.
- Recommendation metrics moves to Library › Your listening, and Hidden
  tracks gets a Library tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:34:51 -04:00
bvandeusenandClaude Opus 5.5 c0c0f8a71f feat: Now Playing's cover grows to fill the screen, and the page takes the cover's dominant colour as Android does (#5592)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m45s
release / go (push) Successful in 2m9s
release / integration (push) Successful in 5m44s
release / android (push) Successful in 5m49s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The web colour helper now picks Palette's dominant swatch (5-bit
quantise, Palette's default filter, median cut to 16, the most-populated
box) instead of the 1x1 mean, so the web and Android agree; PlayerBar's
accent strip shares it. Now Playing draws Android's gradient (55% at the
top, 18% at 45%, then the page) as a masked background colour that fades
over 600ms between tracks, holding the old colour until the new resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:05:05 -04:00
bvandeusenandClaude Opus 5.5 2f2573dd07 fix: the re-tag test sets its clock one statement at a time, as a prepared statement takes only one (M502 #5579)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m9s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:23:16 -04:00
bvandeusenandClaude Opus 5.5 a8786abace feat: Collection › Unmatched lists the library albums no MusicBrainz album matched, each matched by hand from a search or a link, or left off (M502 #5579)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m34s
release / integration (push) Failing after 5m34s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m44s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Like Lidarr's Unmapped Files. A local band's album, which MusicBrainz will
never have, is left off: never matched again and not counted. A re-tagged
album is matched again on the next listing. The Collection's verdict now
says when library albums need matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:30 -04:00
bvandeusenandClaude Opus 5.5 10be068dcc fix: the player bar's heart is Lucide's, outlined then filled as on Android, and its title and artist read at the size of the rest of the page (#5580)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:12:29 -04:00
bvandeusenandClaude Opus 5.5 358849de77 feat: the Wanted view becomes Collection, shaped like Lidarr: an artist catalogue first, the state sections down a left rail (M502 #5577)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m54s
release / integration (push) Successful in 5m33s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m56s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The section opens on a catalogue of every artist on the list, A–Z with a
letter rail, each card showing how many of their albums are held. An artist
opens on their page: every album of theirs, held or not, with its status,
search, releases and tracks. Wanted, Missing files, Activity, Following and
Blocked sit down a left rail as the sections that show state; Held is gone,
the catalogue shows it. The section moves from /wanted to /collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:14:33 -04:00
bvandeusenandClaude Opus 5.5 a4fbe44bd5 feat: the Wanted view lists artists A–Z with their albums inside, each album's tracks a click away (M502 #5572)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m35s
release / android (push) Successful in 5m59s
release / Build signed APK (releases and dev) (push) Successful in 6m10s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The list runs like the library: artists by the library's sort name, each
artist's albums oldest first, grouped under the artist (linked into the
library when it holds them). A filter narrows it to an artist or album by
name, on the server, so it finds what has not been scrolled to. Tracks
opens an album's track list: the library's own for an album it holds, a
lost file marked, otherwise MusicBrainz's standard edition, named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:43:32 -04:00
bvandeusenandClaude Opus 5.5 ca57133c8a fix: a release group whose title names a live, remix or demo version is wanted only when the profile allows that version (M502 #5573)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m51s
release / android (push) Successful in 5m18s
release / integration (push) Successful in 5m23s
release / Build + push container image (push) Successful in 21s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
MusicBrainz types My Chemical Romance's "Common People (Recorded For BBC
Radio 1)" as a plain Single, so a studio-only follow wanted it. Passes now
reads the title's qualifier (brackets, or after a dash) for the version it
names and treats it as a secondary type; a version no type names, an
acoustic or instrumental one, no profile passes. The follow check takes off
the list an open follow want the rule now refuses. Judge counts bbc,
sessions and unplugged as live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:39:35 -04:00
bvandeusenandClaude Opus 5.5 7ef1fca90c fix: an album holding every track of an edition, and more besides, is that release group (M502 #5569)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Savant's "Void" folder holds all 23 tracks of the VOID (DLC) edition plus
20 more, and was refused at 23 of 43. A whole edition of three or more
tracks now matches whatever else the folder holds; singles and two-track
releases still need most of the album's tracks, since many albums hold
their songs. Matcher version 4 retries the refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:14:56 -04:00
bvandeusenandClaude Opus 5.5 1936fe77c6 fix: the matcher-retry test stamps the listing's cutoff after its albums exist, as a listing done before them would not see them (M502 #5569)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m29s
release / android (push) Successful in 5m35s
release / Build signed APK (releases and dev) (push) Successful in 5m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:51:42 -04:00
bvandeusenandClaude Opus 5.5 bf409d5f3f fix: a second copy of a track counts once, joining words don't part two titles, and a newer matcher retries an older one's refusals (M502 #5569)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 5m33s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 6m4s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The second run's refusals were mostly albums holding a track twice (a
two-track single with three files), which no edition can pair, and
release names that differ only in "+" against "/". Tracks are now read
once per disc, number and title, and "and", "&" and "+" count for nothing
when titles are compared; an edition's own title also counts as the
album's name.

library_album_matches records which matcher version decided each album.
Refusals by an older version are matched again, even after the listing is
done, so an improvement reaches them without a migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:44:06 -04:00
bvandeusenandClaude Opus 5.5 80d2fdc3ab fix: under the album's own name, matching track titles are enough to match it; a refusal says which track differed and how (M502 #5569)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m33s
release / android (push) Successful in 4m54s
release / integration (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m6s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
The first run on the library refused singles whose one track has the
album's title ("0 of 1 agree") and near-complete albums (11 of 14), because
every track's length had to fit as well. Lengths now only decide between
groups whose names merely overlap. The reason for a refusal names the first
track that differed, with both titles and lengths, so what is left can be
judged. Migration 0087 clears the refusals so those albums are matched
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:29:42 -04:00
bvandeusenandClaude Opus 5.5 b887ab759b fix: every library album is matched to MusicBrainz before it is listed, by its tags, its artist's discography or a search, and checked against its tracks (M502 #5569)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Verify release artifacts (tag releases only) (push) Skipped
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
The one-time listing took only albums whose tags already named their
release group and artist, and left 340 off the list. The rest are now
matched: the release their tags name, else the artist's release groups
called what the album is called, else a MusicBrainz search by artist and
title. A group found by name is taken only when its tracks agree with the
album's (by disc and number, then title; length and title, or recording),
so a partial copy still matches and a same-titled other album does not.

library_album_matches records each album the listing decided, listed or
unmatched with why, so an album taken off the list is never re-listed. The
albums no match was found for are named on the Held tab. Album rows with
no tracks are no longer counted.

The length and title checks move from the importer to release, shared by
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:16:05 -04:00
bvandeusenandClaude Opus 5.5 2c677b3184 feat: the library's albums go on the wanted list as held, once; after that the list is the truth (M502 #5567)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m31s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
A one-time job in the wanted worker lists every album in the library as a
held want, one per release group, with a record that it happened
(acquisition_settings.library_listed_at). Albums whose tags name only a
release are named on MusicBrainz first, 120 a pass; what nothing names is
counted and shown on the Held tab.

Re-acquisition now wants back only albums on the list. Taking a held album
off the list leaves its files, and it is never fetched back; albums put in
the library folder by hand afterwards are not added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:13:46 -04:00
bvandeusenandClaude Opus 5.5 bc198a9389 fix: the Activity test's page carries clients_down as a value, not a literal Page<T> would refuse (M502 #5523)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m38s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 5m46s
release / Build signed APK (releases and dev) (push) Successful in 5m59s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:35:21 -04:00
bvandeusenandClaude Opus 5.5 8245f4406e feat: download lifecycle — stalled and failed downloads are given up and searched again; imported ones seed, then go (M502 #5523)
release / govulncheck (push) Successful in 17s
release / web (push) Failing after 17s
release / go (push) Successful in 1m11s
release / integration (push) Successful in 4m50s
release / android (push) Successful in 4m54s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Ported from Fabled Librarian's System 104. A new lifecycle poller, every
ten minutes:
- a download the client fails is given up at once;
- one nobody shares, or still fetching its file list, past the stall
  setting (6 h) is asked for again three times, then given up;
- giving up removes the download with its files, holds the release back
  for a week (release_blocks.expires_at), ends the grab and searches the
  album again, with no limit per album;
- a running download the client stops listing for half an hour ends and is
  searched again, not held back; an empty list is not taken as gone;
- an imported download seeds to ratio 2.0 or 14 days, then is removed;
- a download the client would not remove is asked again every pass.

Every condition and act is one open row per (client, download, condition)
in download_interventions. Activity shows what is wrong with a download and
how often it was retried, why one ended, its seeding, and a banner for a
client that does not answer. A running download can be given up by hand
(set aside for good). The seed ratio, seed days and stall hours are on the
search settings card. The download client lookup moves to
acquire.DownloadClientFor, shared by the importer and the lifecycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:27:22 -04:00
bvandeusenandClaude Opus 5.5 d9e00d8bc2 fix: the integrations test's library folder isn't /music, which a Lidarr root folder there is named (M502 #5522)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m5s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:53:43 -04:00
bvandeusenandClaude Opus 5.5 f268b4df09 fix: WantList's prop is list, not state, which Svelte reads as the $state store; the length test's 4-minute case fits within 3% (M502 #5522)
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m40s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m38s
release / android (push) Successful in 5m39s
release / Build signed APK (releases and dev) (push) Successful in 5m51s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:44:21 -04:00
bvandeusenandClaude Opus 5.5 ebbc49b902 feat: import with verification, and a Wanted view of its own (M502 #5522)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.

The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.

The search pass logs why each failed search failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:36:21 -04:00
bvandeusenandClaude Opus 5.5 77081f1cef fix: the search schedule logs its state when it changes, so an idle pass says why (M502 #5521)
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m53s
release / android (push) Successful in 4m54s
release / Build signed APK (releases and dev) (push) Successful in 5m4s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:30:26 -04:00
bvandeusenandClaude Opus 5.5 7f1fe57935 fix: the search test reads the last pass as JSON, since jsonb respaces it (M502 #5521)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m18s
release / android (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:02:18 -04:00
bvandeusenandClaude Opus 5.5 89b3b44779 feat: search and grab — music release names, a quality ladder, a paced search schedule and one live grab per want (M502 #5521)
release / govulncheck (push) Successful in 21s
release / go (push) Failing after 33s
release / web (push) Successful in 56s
release / integration (push) Failing after 4m48s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- internal/release: reads a release name (format, bitrate, bit depth and
  sample rate, source, year, video, discography) and judges it against the
  album wanted: the artist and title must be in it; video rips,
  discographies, other versions (drumless, instrumental, karaoke, remix,
  live unless the album is live…) and singles or EPs for an album are
  refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read
  for format, video files and a track count short of the album.
- internal/acquire: fan-out across every search source with a narrowed
  search kept, a process-wide pacer per source, every candidate judged and
  kept with its reasons, and the grab: one live grab per want, tie-broken
  (a person's grab beats the schedule's; a better release replaces a worse
  one unless it is nearly done). Each download lands in its own folder
  under the client's downloads folder.
- internal/wanted: the search schedule, every ten minutes, never-searched
  first, then requests, missing files and followed artists, taking the
  best release when auto-grab is on (the default).
- Migration 0082: acquisition_settings (auto-grab, interval, allowed
  qualities seeded from Lidarr's "Any"), want search columns,
  want_candidates, grabs, release_blocks.
- Web: the Wanted page shows each want's last search, a Search button and
  every release found with its verdict (Grab, Grab anyway, Never this one,
  Allow again). Integrations gains "Searching and grabbing".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:56:47 -04:00
bvandeusenandClaude Opus 5.5 1a1646a920 fix: the wanted-list tests set a track's MBID with its source, and find the missing-files link in its row (M502 #5520)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m32s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:54:01 -04:00
bvandeusenandClaude Opus 5.5 04515a977c feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:22 -04:00
bvandeusenandClaude Opus 5.5 867f52358c fix: the result card's state prop no longer shadows the $state rune (M502 #5519)
release / govulncheck (push) Successful in 27s
release / web (push) Successful in 1m0s
release / go (push) Successful in 2m4s
release / integration (push) Successful in 4m7s
release / Build signed APK (releases and dev) (push) Successful in 9m44s
release / android (push) Successful in 9m48s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
svelte-check read `$state(false)` as a subscription to the `state` prop.
The prop keeps its name for callers and is bound as cardState inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:12:59 -04:00
bvandeusenandClaude Opus 5.5 77389f88bc feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
release / govulncheck (push) Successful in 22s
release / web (push) Failing after 24s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 4m1s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m37s
release / android (push) Canceled after 4m38s
Minstrel keeps its own record of what artists have released, read from
MusicBrainz, so the wanted list, search and import no longer need Lidarr.

- internal/musicbrainz: searches (artists, release groups, recordings) and
  browses (an artist's release groups; a group's releases with track lists).
  One process-wide pacer at 1.1s, shared with tag enrichment, because
  MusicBrainz's limit is per address. A 503 throttle is retried.
- Migration 0080: mb_artists, mb_release_groups, mb_artist_release_groups
  (a collaboration belongs to each credited artist), mb_releases,
  mb_release_tracks.
- internal/catalogue: Discography and Releases serve the held copy and
  re-read it when older than 7 or 30 days. An unreachable MusicBrainz
  serves the stale copy, marked. An hourly pass re-reads due artists.
- Art for what isn't held comes through /api/catalogue/art from the
  enabled cover-art sources (Cover Art Archive by release group, then the
  name-based sources) and is kept on disk, so the browser never fetches a
  third-party host. A miss is a 404 and the card shows its icon.
- Request search moves from /api/lidarr/search to /api/catalogue/search,
  with the same response shape. Albums are release groups, and "in
  library" matches albums.release_group_mbid. A 10-minute cache absorbs
  search-as-you-type. Discover suggestions point at the art endpoint
  instead of looking up Lidarr.
- Android calls the new path and loads Discover art through ServerImage.
  Installed builds older than this one lose Discover search until they
  update (no compatibility path, rule 22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:08:22 -04:00
bvandeusenandClaude Opus 5.5 a2e1890d4c feat: indexers and download clients you add and configure (M502 #5518)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.

- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
  at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
  session) and FabledExchange, one entry with a switch per job:
  downloads (/api/downloads), search (/api/search) and release details
  (/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
  A download client's Test lists its torrents and checks the downloads
  folder is visible and writable from Minstrel; an Exchange's Test
  reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
  client's own name for it); search sources carry searches per hour.

Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:34:51 -04:00
bvandeusenandClaude Opus 5.5 5306a261c1 feat: duplicates resolve themselves without Lidarr (M502 #5517)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr is off, and with it the resolver had stopped merging: it only
merged when Lidarr had said which copies it mapped. Minstrel is now the
only program writing the library, so the rule is about the copies:

- A song held twice on one album: the extra copies merge into the one
  whose tags fit best (then lossless, larger, older).
- Across releases or under another title: when every copy but one is a
  video rip (held back, or named like one and not marked fine), the rips
  merge into the one that is not.
- Clean copies on different releases are linked as one song, as before.
- Anything else waits for the operator.

Gone with Lidarr: release switches and their settle window, the
unmapped-file read, the rip search, the merge guard and its 409/503,
lidarr.Client's release methods, and duplicate_group_members.lidarr_state
(migration 0078). The duplicates page drops the per-copy Lidarr badge
and the blocked-merge notice; past release changes stay in its history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:12:49 -04:00
bvandeusenandClaude Opus 5.5 8bd48b4302 fix: a merge keeps the song link when the removed copy names the song (M498)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m56s
release / integration (push) Successful in 5m11s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
MergeInheritSongLink took "linked" to mean song_key <> id, but the copy
whose id became the song's key has no song_id of its own. Removing that
copy dropped the survivor out of the song. It now asks whether another
copy shares the key. The test covers both copies as the one removed, so
it no longer passes or fails on which random id sorts first.

The release-change test counted audit rows other tests left behind
(ResetDB keeps audit_log); it now counts what its own pass adds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:53:35 -04:00
bvandeusenandClaude Opus 5.5 e1ad1c9ba2 fix: a release change Lidarr applied but did not answer is recorded (M498)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m43s
release / integration (push) Failing after 5m5s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
On the first deploy pass Lidarr took longer than the client's 30s timeout to
answer the release change for Cracker Island: its album update unlinks the
files and queues the rescan before it replies. The change may well have
landed, but the resolver logged a failure and wrote no audit row, so the
album had no settle window and the report did not show it.

An unanswered PUT (ErrUnreachable) is now read back: when the album's
monitored release is the chosen one, the change is recorded as made. Any
other error, or a read-back that disagrees, stands as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:42:50 -04:00
bvandeusenandClaude Opus 5.5 e17556dce9 feat: Lidarr searches for a better copy of a rip that has none (M498 #5447)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The operator chose option 2: an album holding a video rip that is the only
copy of its song is searched in Lidarr (AlbumSearch), at most once a week
and ten albums a pass. Lidarr grabs only when the quality profile allows an
upgrade, so nothing is removed and the rip stays held back either way.

- lidarr.Client.SearchAlbums posts the AlbumSearch command.
- ListAlbumsWithSoleCopyRips finds the albums: a held-back rip with no
  present clean copy sharing its song or its pending group, and not searched
  in the last week (audit action lidarr_rip_search).
- Suspect sources shows when Lidarr last searched the track's album.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:32:30 -04:00
552 changed files with 45897 additions and 18950 deletions
+2 -2
View File
@@ -28,8 +28,8 @@ ARG MINSTREL_VERSION=dev
ARG MINSTREL_CHANNEL=local
RUN go build -trimpath \
-ldflags="-s -w \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Version=${MINSTREL_VERSION}' \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo.Channel=${MINSTREL_CHANNEL}'" \
-o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim
+17 -15
View File
@@ -1,6 +1,6 @@
# Minstrel
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and Lidarr automation — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
A self-hosted music server that thinks for you. Smart shuffle, contextual likes, ListenBrainz-aware radio, and a wanted list it fetches for you — server-side, so every client (web, mobile, Subsonic third-party) gets the same intelligence.
> State and intelligence belong on the server, not the client.
@@ -11,21 +11,21 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
- **Finds, downloads and files music itself.** A wanted list — requests, followed artists, albums gone missing — searched through indexers *you* configure and downloaded by a client *you* run. Each download is checked against MusicBrainz, tagged, and filed into your library, and a better copy replaces a poor one. Optional, and off until you add a search service and a download client.
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
## Scope and responsible use
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
**Minstrel is a library server.** It indexes files on disk you point it at, and streams them to your own clients.
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB engine of its own, and **no** DRM circumvention of any kind.
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
**Acquisition is optional and inert until you configure it.** You add the search service (a Prowlarr instance or a Torznab indexer) and the download client (qBittorrent) you already run; Minstrel then searches those for the albums on its wanted list, hands the release it picks to your client, and checks and files what arrives. It configures no sources on your behalf: any it uses are ones you added yourself.
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
**What you put in your library, and what sources you configure, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
Minstrel is not affiliated with or endorsed by ListenBrainz, MusicBrainz, Prowlarr, qBittorrent, or Subsonic.
## Quickstart
@@ -40,11 +40,13 @@ services:
ports: ['4533:4533']
volumes:
# Your music library. Point ./music at wherever your audio files
# live. Writable, because Minstrel deletes a file when an admin asks
# it to (for example, quarantine's "Delete file"). It never moves,
# renames or retags anything. The container runs as uid 1000, so that
# user needs write access to the folders. Mount it :ro to forbid even
# deletes: those actions then refuse, say why, and delete nothing.
# live. Writable, because Minstrel files the albums it downloads into
# it (as Artist/Album (Year)/..., tagged with MusicBrainz ids) and
# deletes a file when an admin asks it to (for example, quarantine's
# "Delete file"). Files already there are never moved, renamed or
# retagged. The container runs as uid 1000, so that user needs write
# access to the folders. Mount it :ro to forbid writes: those actions
# then refuse, say why, and change nothing.
- ./music:/music
# Generated data: playlist cover collages, artist art, caches.
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
@@ -89,9 +91,9 @@ With the stack up, a handful of in-app steps get you to a working library. Use y
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
**3. (Optional) Set up acquisition and connections.** In admin **Acquisition** (`/admin/acquisition`), add a search service and a download client so Minstrel can fetch what's wanted; each person adds their own ListenBrainz token (scrobbling + similarity radio) under **Settings → Connections** (`/settings/connections`). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="Search services, download clients and searching in admin Acquisition"></a>
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
@@ -113,7 +115,7 @@ Most operators only need the env vars in the quickstart above. A few extras wort
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
ListenBrainz (per-user scrobble + similarity tokens) and acquisition (search services and download clients) are configured in the UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommendation and events tuning are yaml-only. See [`config.example.yaml`](./config.example.yaml) for the authoritative surface.
@@ -27,8 +27,6 @@ class AdminQuarantineRepository @Inject constructor(
suspend fun resolve(trackId: String) = api.resolve(trackId)
suspend fun deleteFile(trackId: String) = api.deleteFile(trackId)
suspend fun deleteViaLidarr(trackId: String) = api.deleteViaLidarr(trackId)
}
// ── Mappers ──
@@ -49,7 +47,6 @@ private fun AdminQuarantineItemWire.toDomain(): AdminQuarantineItemRef =
artistName = artistName,
albumTitle = albumTitle,
albumId = albumId,
lidarrAlbumMbid = lidarrAlbumMbid,
reportCount = reportCount,
latestAt = latestAt,
reasonCounts = reasonCounts,
@@ -154,7 +154,7 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
SectionCard(
icon = Lucide.Inbox,
title = "Requests",
subtitle = "Approve or reject Lidarr requests",
subtitle = "Approve or reject requests for new music",
count = counts.requests,
onClick = { navController.navigate(AdminRequests) },
)
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -85,7 +84,6 @@ fun AdminQuarantineScreen(
rows = s.rows,
onResolve = viewModel::resolve,
onDeleteFile = viewModel::deleteFile,
onDeleteViaLidarr = viewModel::deleteViaLidarr,
)
}
}
@@ -97,7 +95,6 @@ private fun QueueList(
rows: List<AdminQuarantineItemRef>,
onResolve: (String) -> Unit,
onDeleteFile: (String) -> Unit,
onDeleteViaLidarr: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.trackId }) { row ->
@@ -105,7 +102,6 @@ private fun QueueList(
row = row,
onResolve = { onResolve(row.trackId) },
onDeleteFile = { onDeleteFile(row.trackId) },
onDeleteViaLidarr = { onDeleteViaLidarr(row.trackId) },
)
HorizontalDivider()
}
@@ -117,7 +113,6 @@ private fun QuarantineRow(
row: AdminQuarantineItemRef,
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Column(
modifier = Modifier
@@ -166,7 +161,6 @@ private fun QuarantineRow(
ActionButtons(
onResolve = onResolve,
onDeleteFile = onDeleteFile,
onDeleteViaLidarr = onDeleteViaLidarr,
)
}
}
@@ -175,7 +169,6 @@ private fun QuarantineRow(
private fun ActionButtons(
onResolve: () -> Unit,
onDeleteFile: () -> Unit,
onDeleteViaLidarr: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -183,6 +176,5 @@ private fun ActionButtons(
) {
TextButton(onClick = onResolve) { Text("Resolve") }
OutlinedButton(onClick = onDeleteFile) { Text("Delete file") }
Button(onClick = onDeleteViaLidarr) { Text("Delete + Lidarr") }
}
}
@@ -82,7 +82,6 @@ class AdminQuarantineViewModel @Inject constructor(
fun resolve(trackId: String) = act(trackId) { repository.resolve(it) }
fun deleteFile(trackId: String) = act(trackId) { repository.deleteFile(it) }
fun deleteViaLidarr(trackId: String) = act(trackId) { repository.deleteViaLidarr(it) }
private fun act(trackId: String, action: suspend (String) -> Unit) {
val before = internal.value
@@ -61,7 +61,7 @@ fun AdminRequestsScreen(
AdminRequestsUiState.Loading -> LoadingCentered()
AdminRequestsUiState.Empty -> EmptyState(
title = "No requests waiting",
body = "When users ask Lidarr for new music, their pending " +
body = "When users ask for new music, their pending " +
"requests show up here for approval.",
)
is AdminRequestsUiState.Error -> ErrorRetry(
@@ -19,7 +19,7 @@ import javax.inject.Singleton
* leaks here: (1) the session cookie was being attached to every
* external request the shared OkHttpClient services — including
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
* / Lidarr's /MediaCover endpoints — exposing the session
* — exposing the session
* identifier to third-party logging; (2) a 401 from any of those
* external hosts silently wiped the user's Minstrel session.
* Restricting both attach + clear to placeholder-host requests
@@ -21,7 +21,7 @@ import javax.inject.Singleton
* server URL in Settings without an app relaunch (Phase 11 wiring).
*
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
* rewritten. Absolute external URLs — Lidarr-surfaced artwork from
* rewritten. Absolute external URLs — artwork from
* artwork.musicbrainz.org / coverartarchive.org, for example — must
* reach their authored host unchanged; rewriting them to the
* Minstrel host produced 404s the user saw as missing Discover
@@ -105,21 +105,10 @@ object ErrorCopy {
"cleartext_public" to
"This server is on the internet, so its URL must start with https://. " +
"Plain http:// only works on your home network.",
"lidarr_unreachable" to
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled" to "Lidarr integration is not enabled.",
"lidarr_auth_failed" to "Lidarr authentication failed.",
"lidarr_defaults_incomplete" to
"Lidarr is missing a default quality profile or root folder. " +
"Set them in Admin → Integrations.",
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected" to
"Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed" to
"Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
"album_mbid_missing" to "This track has no Lidarr album to remove.",
"request_not_pending" to "This request is no longer pending.",
"request_not_found" to "That request no longer exists.",
"not_followable" to "Various Artists and MusicBrainz placeholders can't be followed.",
"profile_not_found" to "That release-type profile no longer exists.",
"track_not_found" to "That track no longer exists.",
"library_not_writable" to "The music library isn't writable by the server.",
"file_delete_failed" to "The file couldn't be deleted.",
@@ -8,10 +8,9 @@ import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/quarantine`.
*
* Three resolution endpoints:
* Two resolution endpoints:
* - `resolve` → admin reviewed, no action taken (clears flags).
* - `delete-file` → drop the local file; Lidarr keeps the track.
* - `delete-via-lidarr` → ask Lidarr to remove the track upstream too.
* - `delete-file` → delete the track's file and row.
*/
interface AdminQuarantineApi {
@GET("api/admin/quarantine")
@@ -22,7 +21,4 @@ interface AdminQuarantineApi {
@POST("api/admin/quarantine/{trackId}/delete-file")
suspend fun deleteFile(@Path("trackId") trackId: String)
@POST("api/admin/quarantine/{trackId}/delete-via-lidarr")
suspend fun deleteViaLidarr(@Path("trackId") trackId: String)
}
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
import retrofit2.http.Body
@@ -13,10 +13,11 @@ import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for Discover / Lidarr search / request creation.
* Retrofit interface for Discover / catalogue search / request creation.
*
* `/api/lidarr/search` has a 60s LRU on the server so quick re-types
* of the same query are cheap.
* `/api/catalogue/search` asks MusicBrainz, which allows one request a
* second, so the Discover screen searches when the query is submitted,
* not on every keystroke.
*
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
* Commit B), not here.
@@ -25,11 +26,11 @@ interface DiscoverApi {
@GET("api/discover/suggestions")
suspend fun listSuggestions(): List<ArtistSuggestionWire>
@GET("api/lidarr/search")
@GET("api/catalogue/search")
suspend fun search(
@Query("q") query: String,
@Query("kind") kind: String,
): List<LidarrSearchResultWire>
): List<CatalogueSearchResultWire>
@POST("api/requests")
suspend fun createRequest(@Body body: CreateRequestBody)
@@ -75,8 +75,8 @@ data class LikeTogglePayload(
)
/**
* Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
* Lidarr request that landed during a connectivity hiccup). Optional
* Persisted payload for `MutationKind.REQUEST_CREATE` (a Discover
* request that landed during a connectivity hiccup). Optional
* fields stay null when not applicable; the replayer rebuilds the
* `CreateRequestBody` from these.
*/
@@ -109,19 +109,36 @@ class SyncController @Inject constructor(
}
}
/**
* Pulls pages until the server has nothing newer (#5654). The server
* returns at most 1000 changes a call; taking one page per trigger left
* the cache behind a change log that a full re-read grows by ~26,000
* rows, so merge deletes never arrived and the freshness sweep kept
* asking for tracks that were gone. The cursor is stored after each
* page, so an interrupted pass resumes where it stopped.
*/
private suspend fun sync() {
repeat(MAX_PAGES_PER_PASS) {
if (!syncPage()) return
}
}
/** One page of the change log. True when the server may hold more. */
private suspend fun syncPage(): Boolean {
val meta = syncMetadataDao.get()
val cursor = meta?.cursor ?: 0L
val response = api.sync(cursor)
when (response.code()) {
return when (response.code()) {
HTTP_NO_CONTENT -> {
touchLastSyncAt(cursor)
false
}
HTTP_GONE -> {
wipeAndRetry()
resetCursor()
true
}
HTTP_OK -> {
val body = response.body() ?: return
val body = response.body() ?: return false
applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
syncMetadataDao.upsert(
@@ -131,10 +148,14 @@ class SyncController @Inject constructor(
lastSyncAt = Clock.System.now(),
),
)
// A cursor that did not advance would ask for the same page
// again; stop rather than spin.
body.cursor > cursor
}
else -> {
// Unhandled status — treat as a no-op for v1; the next
// app start retries from the same cursor.
false
}
}
}
@@ -165,25 +186,27 @@ class SyncController @Inject constructor(
)
}
private suspend fun wipeAndRetry() {
private suspend fun resetCursor() {
// The Library DAOs only expose deleteByIds; for the rare 410
// path we re-issue the same request with cursor=0 after
// resetting our cursor. The next response carries the full
// path the next page is requested with cursor=0 after
// resetting our cursor. The following pages carry the full
// entity set, which upsertAll overwrites the existing rows
// with — old rows for entities the server no longer knows
// about will linger until a later 410 or until the user clears
// app data. Acceptable for v1; a future "wipe all" DAO method
// tightens this up.
// about linger until MetadataProvider's refresh meets their
// 404 and drops them.
syncMetadataDao.upsert(
SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
)
sync()
}
private companion object {
const val HTTP_OK = 200
const val HTTP_NO_CONTENT = 204
const val HTTP_GONE = 410
// A guard, not a budget: 1000 pages is a million changes, far past
// anything a library writes between two app starts.
const val MAX_PAGES_PER_PASS = 1000
}
}
@@ -4,13 +4,13 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.RequestKind
import com.fabledsword.minstrel.models.CatalogueResultRef
import com.fabledsword.minstrel.models.SeedContributionRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.CatalogueSearchResultWire
import com.fabledsword.minstrel.models.wire.SeedContributionWire
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
@@ -28,7 +28,7 @@ import javax.inject.Singleton
enum class RequestOutcome { ACCEPTED, QUEUED }
/**
* Read-through Discover surface: Lidarr search, suggestion feed, and
* Read-through Discover surface: catalogue search, suggestion feed, and
* the request-creation write path. Mirrors the Flutter
* `DiscoverApi` + `mutationQueueProvider` integration —
* `feedback_offline_first_for_server_writes` says writes never go
@@ -113,7 +113,7 @@ class DiscoverRepository @Inject constructor(
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
suspend fun search(query: String, kind: RequestKind): List<CatalogueResultRef> =
api.search(query = query, kind = kind.wire).map { it.toDomain() }
/**
@@ -123,7 +123,7 @@ class DiscoverRepository @Inject constructor(
* 2xx, [RequestOutcome.QUEUED] when the call was buffered.
*/
suspend fun createRequest(
kind: LidarrRequestKind,
kind: RequestKind,
artistMbid: String,
artistName: String,
albumMbid: String? = null,
@@ -163,7 +163,7 @@ class DiscoverRepository @Inject constructor(
// ── Mappers (internal — wire types stay out of UI) ──
private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef(
private fun CatalogueSearchResultWire.toDomain(): CatalogueResultRef = CatalogueResultRef(
mbid = mbid,
name = name,
secondaryText = secondaryText,
@@ -38,8 +38,8 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Search as LucideSearch
import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.RequestKind
import com.fabledsword.minstrel.models.CatalogueResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.nav.Discover
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
@@ -161,7 +161,7 @@ private fun SearchBar(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
placeholder = { Text("Search Lidarr for new music") },
placeholder = { Text("Search MusicBrainz for new music") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSubmit() }),
trailingIcon = {
@@ -173,7 +173,7 @@ private fun SearchBar(
}
@Composable
private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) -> Unit) {
private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -181,13 +181,13 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = kind == LidarrRequestKind.ARTIST,
onClick = { onChange(LidarrRequestKind.ARTIST) },
selected = kind == RequestKind.ARTIST,
onClick = { onChange(RequestKind.ARTIST) },
label = { Text("Artists") },
)
FilterChip(
selected = kind == LidarrRequestKind.ALBUM,
onClick = { onChange(LidarrRequestKind.ALBUM) },
selected = kind == RequestKind.ALBUM,
onClick = { onChange(RequestKind.ALBUM) },
label = { Text("Albums") },
)
}
@@ -316,9 +316,9 @@ private fun SuggestionsHeader() {
@Composable
private fun ResultsList(
rows: List<LidarrSearchResultRef>,
rows: List<CatalogueResultRef>,
locallyRequestedMbids: Set<String>,
onRequest: (LidarrSearchResultRef) -> Unit,
onRequest: (CatalogueResultRef) -> Unit,
) {
if (rows.isEmpty()) {
CenteredMessage("Nothing to add for that search yet.")
@@ -25,14 +25,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.CatalogueResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.shared.widgets.ServerImage
@Composable
internal fun SuggestionTile(
@@ -123,7 +123,7 @@ internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
@Composable
internal fun ResultTile(
row: LidarrSearchResultRef,
row: CatalogueResultRef,
locallyRequested: Boolean,
onRequest: () -> Unit,
) {
@@ -173,18 +173,19 @@ private fun Avatar(imageUrl: String, fallback: ImageVector, circular: Boolean) {
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (imageUrl.isEmpty()) {
// The server hands back its own art endpoint (relative, M502 #5519),
// which answers 404 when no source has an image: ServerImage resolves
// the path and shows the icon while loading and on a miss.
ServerImage(
url = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
) {
Icon(
imageVector = fallback,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@@ -8,8 +8,8 @@ import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.discover.data.DiscoverRepository
import com.fabledsword.minstrel.discover.data.RequestOutcome
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.RequestKind
import com.fabledsword.minstrel.models.CatalogueResultRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@@ -24,7 +24,7 @@ import javax.inject.Inject
data class DiscoverState(
val query: String = "",
val kind: LidarrRequestKind = LidarrRequestKind.ARTIST,
val kind: RequestKind = RequestKind.ARTIST,
val suggestions: SuggestionState = SuggestionState.Loading,
val results: ResultsState = ResultsState.Idle,
val locallyRequestedMbids: Set<String> = emptySet(),
@@ -51,7 +51,7 @@ sealed interface ResultsState {
/** No active search — the screen shows the suggestion feed. */
data object Idle : ResultsState
data object Loading : ResultsState
data class Loaded(val items: List<LidarrSearchResultRef>) : ResultsState
data class Loaded(val items: List<CatalogueResultRef>) : ResultsState
data class Error(val message: String) : ResultsState
}
@@ -87,7 +87,7 @@ class DiscoverViewModel @Inject constructor(
}
}
fun setKind(kind: LidarrRequestKind) {
fun setKind(kind: RequestKind) {
internal.update { it.copy(kind = kind) }
if (internal.value.query.isNotBlank()) runSearch()
}
@@ -177,14 +177,14 @@ class DiscoverViewModel @Inject constructor(
* QUEUED) so the screen can pick the snackbar wording and update
* the local "requested" overlay.
*/
suspend fun requestRow(row: LidarrSearchResultRef): RequestOutcome {
suspend fun requestRow(row: CatalogueResultRef): RequestOutcome {
val kind = internal.value.kind
val outcome = repository.createRequest(
kind = kind,
artistMbid = if (kind == LidarrRequestKind.ARTIST) row.mbid else row.artistMbid,
artistName = if (kind == LidarrRequestKind.ARTIST) row.name else row.secondaryText,
albumMbid = if (kind == LidarrRequestKind.ALBUM) row.mbid else null,
albumTitle = if (kind == LidarrRequestKind.ALBUM) row.name else null,
artistMbid = if (kind == RequestKind.ARTIST) row.mbid else row.artistMbid,
artistName = if (kind == RequestKind.ARTIST) row.name else row.secondaryText,
albumMbid = if (kind == RequestKind.ALBUM) row.mbid else null,
albumTitle = if (kind == RequestKind.ALBUM) row.name else null,
)
markRequested(row.mbid)
return outcome
@@ -192,7 +192,7 @@ class DiscoverViewModel @Inject constructor(
suspend fun requestSuggestion(s: ArtistSuggestionRef): RequestOutcome {
val outcome = repository.createRequest(
kind = LidarrRequestKind.ARTIST,
kind = RequestKind.ARTIST,
artistMbid = s.mbid,
artistName = s.name,
)
@@ -18,11 +18,13 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import retrofit2.HttpException
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
private const val MAX_CONCURRENT_FETCHES = 4
private const val HTTP_NOT_FOUND = 404
/**
* Cache-first metadata reads with eager on-miss live-fetch.
@@ -93,26 +95,47 @@ class MetadataProvider @Inject constructor(
fun refreshArtist(id: String): Job = fetchArtist(id)
fun refreshTrack(id: String): Job = fetchTrack(id)
private fun fetchAlbum(id: String): Job = launchDeduped(inFlightAlbums, id) {
private fun fetchAlbum(id: String): Job = launchDeduped(
inFlightAlbums,
id,
onGone = { albumDao.deleteByIds(listOf(id)) },
) {
libraryRepository.refreshAlbumDetail(id)
}
private fun fetchArtist(id: String): Job = launchDeduped(inFlightArtists, id) {
private fun fetchArtist(id: String): Job = launchDeduped(
inFlightArtists,
id,
onGone = { artistDao.deleteByIds(listOf(id)) },
) {
libraryRepository.refreshArtistDetail(id)
}
private fun fetchTrack(id: String): Job = launchDeduped(inFlightTracks, id) {
private fun fetchTrack(id: String): Job = launchDeduped(
inFlightTracks,
id,
onGone = { trackDao.deleteByIds(listOf(id)) },
) {
libraryRepository.refreshTrack(id)
}
/**
* [onGone] runs when the server answers 404: the row no longer exists
* there (a duplicate merge, a deleted file), so the cached copy is
* dropped. Kept, it stays stale forever and [FreshnessSweeper] asks for
* it every pass (#5654). Any other failure leaves the row for a retry.
*/
private fun launchDeduped(
inFlight: ConcurrentHashMap<String, Job>,
id: String,
onGone: suspend () -> Unit,
block: suspend () -> Unit,
): Job = inFlight.computeIfAbsent(id) {
appScope.launch {
try {
fetchGate.withPermit { block() }
} catch (e: HttpException) {
if (e.code() == HTTP_NOT_FOUND) onGone()
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
@@ -25,7 +25,6 @@ data class AdminQuarantineItemRef(
val artistName: String,
val albumTitle: String,
val albumId: String,
val lidarrAlbumMbid: String? = null,
val reportCount: Int = 0,
val latestAt: String = "",
val reasonCounts: Map<String, Int> = emptyMap(),
@@ -4,25 +4,24 @@ import kotlinx.datetime.Instant
import kotlin.math.roundToInt
/**
* Kind of Lidarr request being created. Wire form is the lowercase
* Kind of request being created. Wire form is the lowercase
* enum name; the helper [wire] keeps that mapping in one place.
*/
enum class LidarrRequestKind {
enum class RequestKind {
ARTIST, ALBUM, TRACK;
val wire: String get() = name.lowercase()
}
/**
* Lidarr search hit. Mirrors the Flutter client's
* `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
* Catalogue search hit — `mbid` is the result's own MBID; `artistMbid`
* and `albumMbid` are filled when the row is an album/track and the
* UI needs the parent IDs to build the request.
*
* `inLibrary` and `requested` together cover the "already-handled"
* cases — UI greys the row + swaps the Request button for a pill.
*/
data class LidarrSearchResultRef(
data class CatalogueResultRef(
val mbid: String,
val name: String,
val secondaryText: String = "",
@@ -2,7 +2,7 @@ package com.fabledsword.minstrel.models
/**
* One quarantined track owned by the caller. Mirrors the Flutter
* `QuarantineMineRow` (web `LidarrQuarantineMineRow`).
* `QuarantineMineRow` (web `QuarantineMineRow`).
*
* `reasonLabel` provides the display string mapped from the raw
* server-side reason key — UI renders it as a pill on the row.
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.models
/**
* Status of a Lidarr request. Stored on the wire as a plain string;
* Status of a request. Stored on the wire as a plain string;
* the typed enum keeps switch / when blocks exhaustive and lets the
* UI map directly to a status pill color without raw-string matches.
*/
@@ -23,7 +23,7 @@ enum class RequestStatus {
}
/**
* One Lidarr request the user has submitted. Mirrors
* One request the user has submitted. Mirrors
* the Flutter client's `AdminRequest` —
* shared between the user-side `/api/requests` view and the admin
* cross-user view since the wire shape is identical.
@@ -29,7 +29,6 @@ data class AdminQuarantineItemWire(
@SerialName("artist_name") val artistName: String = "",
@SerialName("album_title") val albumTitle: String = "",
@SerialName("album_id") val albumId: String = "",
@SerialName("lidarr_album_mbid") val lidarrAlbumMbid: String? = null,
@SerialName("report_count") val reportCount: Int = 0,
@SerialName("latest_at") val latestAt: String = "",
@SerialName("reason_counts") val reasonCounts: Map<String, Int> = emptyMap(),
@@ -4,16 +4,17 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* One row of `GET /api/lidarr/search`. Mirrors
* `web/src/lib/api/types.ts LidarrSearchResult` /
* the Flutter client's `LidarrSearchResult`.
* One row of `GET /api/catalogue/search` (the MusicBrainz search, M502
* #5519). Mirrors `web/src/lib/api/types.ts
* CatalogueSearchResult`. `imageUrl` is the server's own art endpoint,
* relative, so it goes through ServerImage.
*
* `inLibrary` and `requested` let the UI greyout rows the user can't
* act on (already imported / already awaiting review). All defaults
* empty so missing keys don't throw on decode.
*/
@Serializable
data class LidarrSearchResultWire(
data class CatalogueSearchResultWire(
val mbid: String = "",
val name: String = "",
@SerialName("secondary_text") val secondaryText: String = "",
@@ -26,8 +27,7 @@ data class LidarrSearchResultWire(
/**
* `GET /api/discover/suggestions` row — an out-of-library artist
* surfaced from ListenBrainz-derived seeds. `imageUrl` is resolved
* on-demand from Lidarr server-side and may be empty.
* surfaced from ListenBrainz-derived seeds. `imageUrl` may be empty.
*/
@Serializable
data class ArtistSuggestionWire(
@@ -93,10 +93,10 @@ data class SnoozeSuggestionBody(
@Serializable
data class CreateRequestBody(
val kind: String,
@SerialName("lidarr_artist_mbid") val artistMbid: String,
@SerialName("artist_mbid") val artistMbid: String,
@SerialName("artist_name") val artistName: String,
@SerialName("lidarr_album_mbid") val albumMbid: String? = null,
@SerialName("album_mbid") val albumMbid: String? = null,
@SerialName("album_title") val albumTitle: String? = null,
@SerialName("lidarr_track_mbid") val trackMbid: String? = null,
@SerialName("track_mbid") val trackMbid: String? = null,
@SerialName("track_title") val trackTitle: String? = null,
)
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
/**
* One row of `GET /api/quarantine/mine`. Mirrors
* the Flutter client's `QuarantineMineRow`
* (web `LidarrQuarantineMineRow`).
* (web `QuarantineMineRow`).
*
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
* / `other`. Stored as a plain string on the wire; the UI does the
@@ -30,8 +30,9 @@ internal data class CatchUp(
* - The mark moves to the newest notice in the page, announced or not, so a
* kind with the phone off is never announced later.
*
* A coalesced notice (tracks missing, now 5) moves its time forward when its
* count grows, so it is announced again with the new count.
* A coalesced notice (tracks missing, now 5) keeps the time it arrived when
* its count grows (M542), so it is announced once: the inbox shows it grown,
* the phone does not buzz for it again.
*/
internal fun planCatchUp(
page: List<CachedNotificationEntity>,
@@ -51,18 +52,34 @@ internal fun planCatchUp(
internal sealed interface Announcement {
data class Each(val items: List<CachedNotificationEntity>) : Announcement
/** Coming back from a day offline is one line, not forty buzzes. */
data class Pile(val count: Int, val channel: ShadeChannel) : Announcement
/**
* Coming back from a day offline is one line, not forty buzzes. [lines]
* are the newest notices' titles, so the pile says what it holds (M542).
*/
data class Pile(
val count: Int,
val channel: ShadeChannel,
val lines: List<String> = emptyList(),
) : Announcement
}
/** How many titles an expanded pile lists, newest first. */
internal const val PILE_LINES = 5
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? = when {
fresh.isEmpty() -> null
fresh.size <= INDIVIDUAL_LIMIT -> Announcement.Each(fresh)
else -> Announcement.Pile(
count = fresh.size,
// A pile goes where its notices would: library health only when every
// one of them is, so a listener's own news is never filed under it.
fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH } ->
Announcement.Pile(fresh.size, ShadeChannel.LIBRARY_HEALTH)
else -> Announcement.Pile(fresh.size, ShadeChannel.YOUR_REQUESTS)
channel = if (fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH }) {
ShadeChannel.LIBRARY_HEALTH
} else {
ShadeChannel.YOUR_REQUESTS
},
lines = fresh.sortedByDescending { it.createdAt }.take(PILE_LINES).map { it.title },
)
}
/** The phone channel per kind, as the user set it. No settings cached: on, the default for every kind. */
@@ -92,3 +109,7 @@ internal fun deliveryWanted(signedIn: Boolean, enabled: Boolean): Boolean = sign
/** One line for a pile. */
internal fun pileText(count: Int): String = "$count new notifications"
/** Under an expanded pile's titles: how many it holds beyond them, or null. */
internal fun pileMore(p: Announcement.Pile): String? =
(p.count - p.lines.size).takeIf { it > 0 }?.let { "and $it more" }
@@ -1,6 +1,7 @@
package com.fabledsword.minstrel.notifications.delivery
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
@@ -50,14 +51,20 @@ class NotificationPoster @Inject constructor(
.setContentIntent(openIntent(item.link, item.id.hashCode()))
.build()
private fun pile(p: Announcement.Pile) =
NotificationCompat.Builder(context, p.channel.id)
/** Collapsed, the count; expanded, the newest titles and how many more. */
private fun pile(p: Announcement.Pile): Notification {
val style = NotificationCompat.InboxStyle().setBigContentTitle(pileText(p.count))
p.lines.forEach { style.addLine(it) }
pileMore(p)?.let { style.setSummaryText(it) }
return NotificationCompat.Builder(context, p.channel.id)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Minstrel")
.setContentText(pileText(p.count))
.setContentTitle(pileText(p.count))
.setContentText(p.lines.joinToString(" · "))
.setStyle(style)
.setAutoCancel(true)
.setContentIntent(openIntent(link = null, requestCode = PILE_ID))
.build()
}
/** Each notice gets its own request code, so their links stay apart. */
private fun openIntent(link: String?, requestCode: Int): PendingIntent =
@@ -9,18 +9,23 @@ import com.fabledsword.minstrel.nav.Requests
/**
* The app screen for a notice's link. The server writes web paths, the same
* for every client; this maps them onto the app's routes. Admin pages the app
* has no screen for (missing files, duplicates, playback errors) open the
* Admin landing; anything unknown opens nothing.
* for every client; this maps them onto the app's routes. The library's
* health pages sit under Collection on the web (M504): Reports opens the
* app's report screen, and the ones the app has no screen for (duplicates,
* suspect sources, missing files) open the Admin landing, as do other Admin
* pages; anything unknown opens nothing.
*/
private val ADMIN_COLLECTION_PAGES = setOf("duplicates", "suspect-sources", "missing")
internal fun routeForLink(link: String): Any? {
val parts = link.trim('/').split('/').filter { it.isNotEmpty() }
val parts = link.substringBefore('?').trim('/').split('/').filter { it.isNotEmpty() }
return when {
parts.size == 2 && parts[0] == "albums" -> AlbumDetail(parts[1])
parts.size == 2 && parts[0] == "artists" -> ArtistDetail(parts[1])
parts == listOf("requests") -> Requests
parts == listOf("admin", "requests") -> AdminRequests
parts == listOf("admin", "quarantine") -> AdminQuarantine
parts == listOf("collection", "reports") -> AdminQuarantine
parts.firstOrNull() == "collection" && parts.getOrNull(1) in ADMIN_COLLECTION_PAGES -> Admin
parts.firstOrNull() == "admin" -> Admin
else -> null
}
@@ -18,7 +18,7 @@ import javax.inject.Singleton
enum class CancelOutcome { Synced, Queued }
/**
* Read-through accessor for the caller's own Lidarr requests +
* Read-through accessor for the caller's own requests +
* cancel-while-pending write. No Room caching for v1 — same rationale
* as HistoryRepository; the offline-snapshot mirror lands with a
* later slice. Cancel is offline-first: direct REST on the happy
@@ -78,7 +78,7 @@ fun RequestsScreen(
UiState.Loading -> LoadingCentered()
UiState.Empty -> EmptyState(
title = "Nothing requested yet",
body = "Use Discover to ask Lidarr for new music; your " +
body = "Use Discover to ask for new music; your " +
"requests show up here.",
)
is UiState.Error -> ErrorRetry(
@@ -218,7 +218,7 @@ private fun CancelConfirmDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Cancel request?") },
text = { Text("Cancel “$displayName”? Lidarr will stop searching for it.") },
text = { Text("Cancel “$displayName”? Your request is withdrawn.") },
confirmButton = {
Button(onClick = onConfirm) { Text("Cancel request") }
},
@@ -135,9 +135,8 @@ class AuthCookieInterceptorTest {
}
// Drift #569 regression guard: a 401 from an external host must
// NOT wipe the user's Minstrel session. A misbehaving CDN or a
// Lidarr that's 401-ing on /MediaCover URLs used to silently sign
// the user out.
// NOT wipe the user's Minstrel session. A misbehaving CDN that's
// 401-ing on artwork URLs used to silently sign the user out.
@Test
fun `does NOT clear cookie on 401 from non-placeholder host`() {
authStore.setSessionCookie("session=keep")
@@ -67,7 +67,7 @@ class BaseUrlInterceptorTest {
@Test
fun `passes external absolute URL through unchanged`() {
// Lidarr-surfaced artwork hosts (artwork.musicbrainz.org,
// External artwork hosts (artwork.musicbrainz.org,
// coverartarchive.org) are absolute external URLs. The
// interceptor must NOT rewrite them to the Minstrel base —
// doing so produces 404s the user saw as missing Discover
@@ -0,0 +1,111 @@
package com.fabledsword.minstrel.cache.sync
import com.fabledsword.minstrel.api.endpoints.SyncApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.models.wire.SyncDeletesWire
import com.fabledsword.minstrel.models.wire.SyncResponseWire
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
import retrofit2.Response
import retrofit2.Retrofit
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The server returns at most 1000 changes a call (#5654). One page per
* trigger left the cache behind the change log, so deletes from duplicate
* merges never arrived; a pass pulls pages until the server answers 204.
*/
class SyncControllerTest {
private val api: SyncApi = mockk()
private val trackDao: CachedTrackDao = mockk(relaxed = true)
private val retrofit: Retrofit = mockk {
every { create(SyncApi::class.java) } returns api
}
private val authStore: AuthStore = mockk {
every { sessionCookie } returns MutableStateFlow(null)
}
private val networkStatus: NetworkStatusController = mockk {
// Offline, so construction starts no pass; each test runs one itself.
every { state } returns MutableStateFlow(ServerHealth.Offline)
}
// The cursor round-trips through the DAO, so it is held for real.
private var stored: SyncMetadataEntity? = null
private val metadataDao: SyncMetadataDao = mockk {
coEvery { get() } answers { stored }
coEvery { upsert(any()) } answers { stored = firstArg() }
}
private fun page(cursor: Long, deletedTracks: List<String> = emptyList()) =
Response.success(SyncResponseWire(cursor = cursor, deletes = SyncDeletesWire(track = deletedTracks)))
private fun noContent(): Response<SyncResponseWire> = Response.success(204, null as SyncResponseWire?)
@Test
fun `pages are pulled until the server has nothing newer`() = runTest {
coEvery { api.sync(0) } returns page(1000)
coEvery { api.sync(1000) } returns page(2000, deletedTracks = listOf("merged-away"))
coEvery { api.sync(2000) } returns noContent()
val controller = SyncController(
metadataDao, mockk<CachedArtistDao>(relaxed = true), mockk<CachedAlbumDao>(relaxed = true),
trackDao, authStore, backgroundScope, networkStatus, retrofit,
)
controller.syncSafe()
assertNull(controller.lastSyncError.value)
coVerify(exactly = 1) { api.sync(2000) }
coVerify { trackDao.deleteByIds(listOf("merged-away")) }
assertEquals(2000L, stored?.cursor)
}
@Test
fun `a cursor that does not advance stops the pass`() = runTest {
stored = SyncMetadataEntity(cursor = 500)
coEvery { api.sync(500) } returns page(500)
val controller = SyncController(
metadataDao, mockk<CachedArtistDao>(relaxed = true), mockk<CachedAlbumDao>(relaxed = true),
trackDao, authStore, backgroundScope, networkStatus, retrofit,
)
controller.syncSafe()
assertNull(controller.lastSyncError.value)
coVerify(exactly = 1) { api.sync(500) }
}
@Test
fun `a stale cursor resets to zero and keeps pulling`() = runTest {
stored = SyncMetadataEntity(cursor = 7)
coEvery { api.sync(7) } returns Response.error(410, "".toResponseBody())
coEvery { api.sync(0) } returns page(1000)
coEvery { api.sync(1000) } returns noContent()
val controller = SyncController(
metadataDao, mockk<CachedArtistDao>(relaxed = true), mockk<CachedAlbumDao>(relaxed = true),
trackDao, authStore, backgroundScope, networkStatus, retrofit,
)
controller.syncSafe()
assertNull(controller.lastSyncError.value)
coVerify(exactly = 1) { api.sync(1000) }
assertEquals(1000L, stored?.cursor)
}
}
@@ -13,7 +13,10 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
import retrofit2.HttpException
import retrofit2.Response
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertTrue
@@ -36,6 +39,30 @@ class MetadataProviderTest {
coVerify(exactly = 1) { library.refreshAlbumDetail("a1") }
}
// A row the server no longer has (a duplicate merge) is dropped, or the
// freshness sweep asks for it every 30 minutes forever (#5654).
@Test
fun `a 404 drops the cached row`() = runTest {
coEvery { library.refreshTrack("t1") } throws httpError(404)
val provider = MetadataProvider(albumDao, artistDao, trackDao, library, this)
provider.refreshTrack("t1").join()
coVerify(exactly = 1) { trackDao.deleteByIds(listOf("t1")) }
}
@Test
fun `any other failure keeps the cached row`() = runTest {
coEvery { library.refreshAlbumDetail("a1") } throws httpError(500)
val provider = MetadataProvider(albumDao, artistDao, trackDao, library, this)
provider.refreshAlbum("a1").join()
coVerify(exactly = 0) { albumDao.deleteByIds(any()) }
}
private fun httpError(status: Int) = HttpException(Response.error<Unit>(status, "".toResponseBody()))
@Test
fun `at most four fetches run concurrently`() = runTest {
val scope = this
@@ -99,6 +99,16 @@ class DeliveryPlanTest {
assertEquals(5, pile.count)
assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel)
assertEquals("5 new notifications", pileText(pile.count))
assertEquals(listOf("t-n5", "t-n4", "t-n3", "t-n2", "t-n1"), pile.lines)
assertNull(pileMore(pile))
}
@Test
fun `a pile names its newest notices and counts the rest`() {
val many = (1..17).map { notice("n$it", it.toLong()) }
val pile = assertIs<Announcement.Pile>(announcementFor(many))
assertEquals(listOf("t-n17", "t-n16", "t-n15", "t-n14", "t-n13"), pile.lines)
assertEquals("and 12 more", pileMore(pile))
}
@Test
@@ -23,9 +23,11 @@ class NotificationUiTest {
assertEquals(ArtistDetail("ar-1"), routeForLink("/artists/ar-1"))
assertEquals(Requests, routeForLink("/requests"))
assertEquals(AdminRequests, routeForLink("/admin/requests"))
assertEquals(AdminQuarantine, routeForLink("/admin/quarantine"))
// Admin pages the app has no screen for open the Admin landing.
assertEquals(Admin, routeForLink("/admin/duplicates"))
assertEquals(AdminQuarantine, routeForLink("/collection/reports"))
// Library health pages the app has no screen for open the Admin landing.
assertEquals(Admin, routeForLink("/collection/duplicates"))
assertEquals(Admin, routeForLink("/collection/duplicates?view=resolved"))
assertEquals(Admin, routeForLink("/collection/missing"))
assertEquals(Admin, routeForLink("/admin"))
assertNull(routeForLink("/somewhere-new"))
}
+73 -49
View File
@@ -9,28 +9,30 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/gc"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
@@ -38,6 +40,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
func main() {
@@ -133,7 +136,18 @@ func run() error {
if fpErr != nil {
logger.Warn("fingerprint settings: using defaults", "err", fpErr)
}
scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings)
// The library's folders (#6129): the ones saved in Settings, or
// MINSTREL_LIBRARY_SCAN_PATHS until any are. A load failure falls back to
// env, which is what the server read before the setting existed.
roots, rootsSaved, rootsErr := library.LoadRoots(ctx, pool, cfg.Library.ScanPaths)
switch {
case rootsErr != nil:
logger.Warn("library folders: using MINSTREL_LIBRARY_SCAN_PATHS", "err", rootsErr)
case rootsSaved && len(cfg.Library.ScanPaths) > 0 && !slices.Equal(roots, cfg.Library.ScanPaths):
logger.Warn("library folders: the ones saved in Settings are used, not MINSTREL_LIBRARY_SCAN_PATHS",
"settings", roots, "env", cfg.Library.ScanPaths)
}
scanner := library.New(pool, logger, roots, fpSettings)
// Loudness analysis settings (M464 #4995): shared by the loudness backfill
// and the admin API, and falls back to the defaults like the above.
@@ -148,8 +162,11 @@ func run() error {
if acoustIDErr != nil {
logger.Warn("acoustid settings: using defaults (lookup off)", "err", acoustIDErr)
}
// One AcoustID client, so the lookup worker and the import's check share
// its pace (AcoustID allows three requests a second per key).
acoustIDClient := acoustid.New(acoustid.DefaultBaseURL)
acoustIDLookup := library.NewAcoustIDLookupWorker(pool, logger.With("component", "acoustid_lookup"),
acoustIDSettings, acoustid.New(acoustid.DefaultBaseURL))
acoustIDSettings, acoustIDClient)
contact := cfg.Library.ContactEmail
if contact == "" {
@@ -190,7 +207,7 @@ func run() error {
// One unified scan chain: library walk → MBID backfill → cover enrich.
// Boot-time scan and manual-trigger scans share this path; results land
// in scan_runs for the admin overview.
if cfg.Library.ScanOnStartup && len(cfg.Library.ScanPaths) > 0 {
if cfg.Library.ScanOnStartup && len(roots) > 0 {
go func() {
if _, err := library.RunScan(ctx, pool, scanner, coverEnricher,
logger.With("component", "scan_run"),
@@ -291,10 +308,8 @@ func run() error {
gcWorker := gc.NewWorker(pool, logger.With("component", "gc"))
go gcWorker.Run(ctx)
// Start the Lidarr reconciler worker. Per spec §M5a, polls pending Lidarr
// import requests and reconciles them against the library. Short-circuits
// to no-op when lidarr_config.enabled = false.
lidarrCfg := lidarrconfig.New(pool)
// The request reconciler completes approved requests once the library
// holds what they asked for (M502 #5520).
// Live-event bus shared between SSE subscribers (api.Mount) and
// background workers that publish (reconciler today; scanner later).
// Constructed before any service that publishes so they all share the
@@ -304,45 +319,62 @@ func run() error {
// threading the bus through RunScan + TryStartScan + the Scheduler
// + every test caller. Per-process singleton, set once at startup.
library.SetEventBus(bus)
// Per-tick Lidarr client factory: re-reads config so an admin save
// takes effect without a restart, returning nil while Lidarr is
// disabled/unconfigured. Mirrors server.go's lidarrClientFn; the
// reconciler uses it to (re)send unconfirmed adds.
lidarrClientFn := func() *lidarr.Client {
c, cerr := lidarrCfg.Get(ctx)
if cerr != nil || !c.Enabled || c.BaseURL == "" || c.APIKey == "" {
return nil
}
return lidarr.NewClient(c.BaseURL, c.APIKey)
}
// The notifications inbox's one writer (M489), shared by every
// background producer started here. The API builds its own over the
// same pool and bus.
notifier := notifications.New(pool, bus, logger.With("component", "notifications"))
lidarrReconciler := lidarrrequests.NewReconciler(pool, lidarrCfg, lidarrClientFn, logger.With("component", "lidarr"), bus)
lidarrReconciler.SetNotifier(notifier)
requestReconciler := requests.NewReconciler(pool, logger.With("component", "requests"), bus)
requestReconciler.SetNotifier(notifier)
library.SetNotifier(notifier)
go lidarrReconciler.Run(ctx)
go requestReconciler.Run(ctx)
// Duplicate resolver (M498): classifies the sweep's groups and, with the
// operator's auto-resolve on, merges copies Lidarr does not map and moves a
// Lidarr release that lists songs twice. Started after SetNotifier so its
// summaries reach admins. See internal/library/duplicate_resolve.go.
resolverLidarr := func() library.LidarrLibrary {
if c := lidarrClientFn(); c != nil {
return c
}
return nil // a nil *lidarr.Client must not become a non-nil interface
// Release catalogue (M502 #5519): re-reads discographies older than a
// week from MusicBrainz, a batch an hour, through the process-wide
// MusicBrainz pacer it shares with tag enrichment.
cat := catalogue.New(pool, nil, logger.With("component", "catalogue"))
go cat.Run(ctx)
// The wanted list (M502 #5520): reads followed artists' discographies
// daily for new releases and marks wants held once the library has them.
go wanted.New(pool, cat, logger.With("component", "wanted")).Run(ctx)
// Search and grab (M502 #5521): every ten minutes, searches the
// configured indexers for wants due, paced by each source's
// searches-per-hour, and hands the best release to the download client
// when auto-grab is on.
go wanted.NewSearcher(pool, cat, logger.With("component", "search")).Run(ctx)
// Import with verification (M502 #5522): every minute, finished
// downloads are checked against their album (AcoustID, lengths, tags)
// and filed into the library with MusicBrainz tags, or refused.
var printer importer.Printer
if acoustIDSettings != nil {
printer = &importer.AcoustIDPrinter{Settings: acoustIDSettings, Client: acoustIDClient}
}
imp := importer.New(pool, scanner, cat, printer, scanner.Roots, logger.With("component", "import"))
imp.DataDir = cfg.Storage.DataDir
go imp.Run(ctx)
// Download lifecycle (M502 #5523): every ten minutes, a download that
// failed or that nobody shares is retried, then given up and its album
// searched again; an imported one seeds, then is removed.
go lifecycle.New(pool, logger.With("component", "lifecycle")).Run(ctx)
// Duplicate resolver (M498, M502): classifies the sweep's groups and, with
// the operator's auto-resolve on, merges the copies that are safe to merge.
// Started after SetNotifier so its summaries reach admins. It asks the
// catalogue for the track list of an album whose wrong slot it must
// decide, a few a pass. See internal/library/duplicate_resolve.go.
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
cfg.Storage.DataDir, fpSettings, resolverLidarr).Run(ctx)
cfg.Storage.DataDir, fpSettings, library.ResolveSources{
TrackLists: cat.RefreshReleaseGroup, Identify: acoustIDLookup.IdentifyRecordings,
}).Run(ctx)
// Missing-file re-acquisition (milestone #290). Turns albums whose files
// have been gone longer than the grace window into Lidarr requests, on an
// exponential per-album backoff. Hourly tick — the shortest meaningful
// backoff is measured in hours, so waking more often would only re-read
// settings and find nothing due.
// Missing-file re-acquisition (milestone #290, M502 #5520). Wants back
// albums whose files have been gone longer than the grace window; the
// search schedule looks for them. Hourly tick — the grace window is
// measured in hours.
//
// A settings-load failure is logged, not fatal: NewSettingsService always
// returns a usable service holding the defaults, and running on defaults
@@ -353,14 +385,7 @@ func run() error {
if reacqErr != nil {
logger.Warn("reacquisition: using default settings", "err", reacqErr)
}
reacqSweeper := reacquisition.NewSweeper(
pool,
reacqSettings,
lidarrrequests.NewService(pool, lidarrCfg, lidarrClientFn, nil),
logger.With("component", "reacquisition"),
)
reacqSweeper.SetNotifier(notifier)
go reacqSweeper.Run(ctx)
go reacquisition.NewSweeper(pool, reacqSettings, logger.With("component", "reacquisition")).Run(ctx)
// library_changes compactor (#357 follow-up). Daily tick; deletes
// rows older than the configured retention so the change-log table
@@ -421,8 +446,7 @@ func run() error {
// Filesystem watcher: near-instant pickup of new/changed files via
// fsnotify, scanning just the affected paths + enriching their albums
// inline. Replaces the removed configurable scan scheduler.
watcher := library.NewWatcher(scanner, coverEnricher,
logger.With("component", "watcher"), cfg.Library.ScanPaths)
watcher := library.NewWatcher(scanner, coverEnricher, logger.With("component", "watcher"))
go func() {
if werr := watcher.Run(ctx); werr != nil {
logger.Warn("library watcher exited", "err", werr)
+5 -1
View File
@@ -24,6 +24,10 @@ log:
library:
# Filesystem roots to scan for music. Each path is walked recursively.
# These are the starting folders only: once they are changed in
# Admin › Library, the saved ones are used and this value is ignored.
# Changing a folder there moves its tracks with it, so moving the library
# to a new mount keeps every play, like and playlist.
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
scan_paths: []
# Kick off a scan on server startup. On by default so a fresh install
@@ -41,7 +45,7 @@ library:
# Env: MINSTREL_BRANDING_APP_NAME, MINSTREL_BRANDING_DESCRIPTION
branding:
app_name: "Minstrel"
description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and Lidarr integration."
description: "Self-hosted music server with Subsonic compatibility, smart shuffle, and music acquisition."
subsonic:
# Gate for the legacy `p=` plaintext password parameter on /rest/*.
+2 -2
View File
@@ -13,7 +13,7 @@ Publish the port to the network and use `http://<host>:4533`:
ports: ['4533:4533']
```
If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP
If nothing sits in front of Minstrel, set **Admin › Email & network → Client IP
detection** to 0. It defaults to 1, which assumes one proxy (see below for
why that matters).
@@ -48,7 +48,7 @@ Three things, all required:
the app is. Raise the proxy's read timeout well past a minute (nginx:
`proxy_read_timeout 1h;`).
3. **Tell Minstrel where it lives**, in **Admin → Integrations**:
3. **Tell Minstrel where it lives**, in **Admin › Email & network**:
- **Client IP detection:** the number of proxies in front of Minstrel. One
proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2.
Minstrel uses this to find the real client address, for login rate
+1 -1
View File
@@ -11,7 +11,7 @@ across all shots for a consistent look.
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
| `register.png` | `/register` | The account-creation form on a fresh instance |
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
| `integrations.png` | `/admin/acquisition` | Indexers, download clients and searching |
| `android-download.png` | `/settings` | The "Install the Android app" download card |
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
+2
View File
@@ -1,5 +1,7 @@
# Project Spec: Smart Music Client (companion app)
> **History.** This is the original design spec, kept as written. Where it describes an outside library manager doing search, adds and deletes, Minstrel now does that itself (M502).
## 1. Overview
A mobile music player client built as the intentional companion to the Smart Music Server (see `smart-music-server-spec.md`). This is not a general-purpose Subsonic client — it is the client that knows how to consume the server's extended API and surface its novel features (contextual likes, session-aware radio, Lidarr integration, server-canonical state) as first-class UX.
+2
View File
@@ -1,5 +1,7 @@
# Project Spec: Smart Music Server (working name)
> **History.** This is the original design spec, kept as written. Where it describes an outside library manager doing search, adds and deletes, Minstrel now does that itself (M502).
## 1. Overview
A self-hosted music server and streaming backend built to close a real gap in the self-hosted music ecosystem: **state and intelligence belong on the server, not the client.** Existing Subsonic-compatible servers (Navidrome, Gonic, Airsonic-Advanced) defer all of the "smart" behavior — shuffle logic, radio, play count semantics, like management — to clients. The result is fragmented experience across devices: a song liked on one client is unknown to another; shuffle quality depends on which app you opened; play counts diverge. This project centralizes those decisions on the server so every client sees the same canonical state.
+7 -3
View File
@@ -198,10 +198,14 @@ func TestLookup_KeepsToTheRateLimit(t *testing.T) {
t.Fatal(err)
}
}
// The client spaces its sends by minInterval; the server sees arrivals,
// which transit can bunch by a few milliseconds (389ms under -race, CI run
// 9543). What AcoustID enforces is 3 a second at its end, so that is the
// bound: minInterval's headroom is what absorbs the bunching.
const serviceLimit = time.Second / 3
for i := 1; i < len(at); i++ {
// A little slack for the server's own clock reading.
if gap := at[i].Sub(at[i-1]); gap < minInterval-10*time.Millisecond {
t.Errorf("requests %d and %d were %s apart, want at least %s", i-1, i, gap, minInterval)
if gap := at[i].Sub(at[i-1]); gap < serviceLimit {
t.Errorf("requests %d and %d arrived %s apart, want at least %s", i-1, i, gap, serviceLimit)
}
}
}
+180
View File
@@ -0,0 +1,180 @@
package acquire
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
)
func validUpdate() Update {
return Update{Label: "Prowlarr", BaseURL: "http://prowlarr:9696", TimeoutMs: 30000, LocalPath: "/downloads", SearchesPerHour: 120}
}
func TestUpdateValidate(t *testing.T) {
if err := validUpdate().validate(); err != nil {
t.Fatalf("a valid form: %v", err)
}
for name, tc := range map[string]struct {
edit func(*Update)
field string
}{
"no name": {func(u *Update) { u.Label = "" }, "label"},
"switched on with no address": {func(u *Update) { u.Enabled, u.BaseURL = true, "" }, "base_url"},
"an address with no scheme": {func(u *Update) { u.BaseURL = "prowlarr:9696" }, "base_url"},
"a timeout too short": {func(u *Update) { u.TimeoutMs = 10 }, "timeout_ms"},
"a relative downloads folder": {func(u *Update) { u.LocalPath = "downloads" }, "local_path"},
"a relative client folder": {func(u *Update) { u.RemotePath = "data" }, "remote_path"},
"too many searches": {func(u *Update) { u.SearchesPerHour = 4000 }, "searches_per_hour"},
} {
u := validUpdate()
tc.edit(&u)
var invalid *InvalidError
if err := u.validate(); !errors.As(err, &invalid) || invalid.Field != tc.field {
t.Errorf("%s: err = %v, want an InvalidError on %s", name, err, tc.field)
}
}
// Switched off, an empty address is a row still being set up.
u := validUpdate()
u.BaseURL = ""
if err := u.validate(); err != nil {
t.Errorf("switched off with no address: %v", err)
}
}
func TestSourceFor_BuildsTheClientTheProtocolNames(t *testing.T) {
for protocol, check := range map[string]func(Source) bool{
ProtocolProwlarr: func(s Source) bool { _, ok := s.Search.(*prowlarr.Client); return ok && s.Fetch != nil },
ProtocolTorznab: func(s Source) bool { _, ok := s.Search.(*indexer.Torznab); return ok && s.Fetch != nil },
// Exchange's results are all magnets: nothing to fetch through it.
ProtocolExchange: func(s Source) bool { _, ok := s.Search.(*exchange.Client); return ok && s.Fetch == nil },
} {
src := SourceFor(dbq.AcquisitionService{Code: protocol, Protocol: protocol, SearchesPerHour: 7})
if !check(src) || src.Code != protocol || src.PerHour != 7 {
t.Errorf("%s: source %+v", protocol, src)
}
}
}
func TestJobs_AnExchangeDoesWhatItsSwitchesAllow(t *testing.T) {
ex := dbq.AcquisitionService{Kind: KindDownload, Protocol: ProtocolExchange, ServesSearch: true}
if !Searches(ex) || Fetches(ex) {
t.Error("an exchange with only search on should search and not fetch")
}
// A qBittorrent's switches mean nothing: it fetches, whatever they say.
qb := dbq.AcquisitionService{Kind: KindDownload, Protocol: ProtocolQBittorrent, ServesSearch: true}
if Searches(qb) || !Fetches(qb) {
t.Error("a qBittorrent fetches and never searches")
}
}
func TestTest_NoAddressIsNothingToReach(t *testing.T) {
r := Test(context.Background(), dbq.AcquisitionService{Protocol: ProtocolProwlarr, Kind: KindSearch})
if r.OK || !strings.Contains(r.Detail, "No address") {
t.Fatalf("result = %+v", r)
}
}
func TestTest_SearchReportsResultsAndARefusedKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "good" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`[{"title": "Radiohead - OK Computer"}]`))
}))
defer srv.Close()
row := dbq.AcquisitionService{Kind: KindSearch, Protocol: ProtocolProwlarr, BaseUrl: srv.URL, ApiKey: "good", TimeoutMs: 5000}
if r := Test(context.Background(), row); !r.OK || !strings.Contains(r.Detail, "1 results") {
t.Errorf("a working key: %+v", r)
}
row.ApiKey = "bad"
if r := Test(context.Background(), row); r.OK || !strings.Contains(r.Detail, "refused") {
t.Errorf("a refused key: %+v", r)
}
}
// fakeExchange answers the three native APIs.
func fakeExchange(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/downloads":
_, _ = w.Write([]byte(`{"enabled": true, "running": true, "inbound_conns": 3, "downloads": []}`))
case r.URL.Path == "/api/search":
_, _ = w.Write([]byte(`{"total": 0, "results": []}`))
case strings.HasPrefix(r.URL.Path, "/api/torrents/"):
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error": "unknown torrent"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestTest_AnExchangeReportsEachJobSwitchedOn(t *testing.T) {
srv := fakeExchange(t)
row := dbq.AcquisitionService{
Kind: KindDownload, Protocol: ProtocolExchange, BaseUrl: srv.URL, TimeoutMs: 5000,
ServesDownloads: true, ServesSearch: true, ServesEnrichment: true, LocalPath: t.TempDir(),
}
r := Test(context.Background(), row)
if !r.OK {
t.Fatalf("all three jobs: %+v", r)
}
for _, job := range []string{"Downloads:", "Search:", "Release details:"} {
if !strings.Contains(r.Detail, job) {
t.Errorf("detail %q does not report %s", r.Detail, job)
}
}
row.ServesDownloads, row.ServesEnrichment = false, false
if r := Test(context.Background(), row); strings.Contains(r.Detail, "Downloads:") || !strings.Contains(r.Detail, "Search:") {
t.Errorf("only search on: %+v", r)
}
row.ServesSearch = false
if r := Test(context.Background(), row); r.OK || !strings.Contains(r.Detail, "switched off") {
t.Errorf("every job off: %+v", r)
}
}
func TestTest_ADownloadClientNeedsAFolderMinstrelCanSee(t *testing.T) {
srv := fakeExchange(t)
row := dbq.AcquisitionService{
Kind: KindDownload, Protocol: ProtocolExchange, BaseUrl: srv.URL, TimeoutMs: 5000,
ServesDownloads: true, Category: DefaultCategory,
LocalPath: filepath.Join(t.TempDir(), "not-mounted"),
}
r := Test(context.Background(), row)
if r.OK || !strings.Contains(r.Detail, "cannot see its downloads folder") {
t.Fatalf("a missing folder: %+v", r)
}
row.LocalPath = t.TempDir()
if r := Test(context.Background(), row); !r.OK || !strings.Contains(r.Detail, `"minstrel" category`) {
t.Errorf("a visible, writable folder: %+v", r)
}
}
func TestDownloadClient_WrapsThePathMapping(t *testing.T) {
d, err := DownloadClient(dbq.AcquisitionService{Protocol: ProtocolQBittorrent, LocalPath: "/downloads", RemotePath: "/data"})
if err != nil {
t.Fatal(err)
}
if _, plain := d.(*downloader.Client); plain {
t.Error("a row naming two folders should come back wrapped in its mapping")
}
if _, err := DownloadClient(dbq.AcquisitionService{Protocol: ProtocolProwlarr}); err == nil {
t.Error("a search protocol is not a download client")
}
}
+45
View File
@@ -0,0 +1,45 @@
package acquire
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Build makes a Service from the settings as they stand: every enabled
// search source behind one paced Fanout, the first enabled download client,
// and the quality profile. Built afresh for each pass and each request, so a
// changed setting takes effect on the next one.
func Build(ctx context.Context, pool *pgxpool.Pool) (*Service, dbq.AcquisitionSetting, error) {
q := dbq.New(pool)
settings, err := q.GetAcquisitionSettings(ctx)
if err != nil {
return nil, settings, fmt.Errorf("acquire: read settings: %w", err)
}
sources, err := Sources(ctx, pool)
if err != nil {
return nil, settings, fmt.Errorf("acquire: read search sources: %w", err)
}
clients, err := q.ListUsableAcquisitionServices(ctx, JobDownload)
if err != nil {
return nil, settings, fmt.Errorf("acquire: read download clients: %w", err)
}
s := &Service{Profile: release.NewProfile(settings.AllowedQualities), Memory: NewMemory(pool)}
fan := &Fanout{Sources: sources, Pace: SearchPace}
if len(sources) > 0 {
s.Search = fan
}
if len(clients) > 0 {
row := clients[0]
client, err := DownloadClient(row)
if err != nil {
return nil, settings, fmt.Errorf("acquire: download client %s: %w", row.Code, err)
}
s.Grabber = &Downloader{Client: client, Code: row.Code, Landing: row.LocalPath, Fetch: fan}
}
return s, settings, nil
}
+165
View File
@@ -0,0 +1,165 @@
package acquire
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
// Searching every source at once (M502 #5521), ported from Fabled Librarian
// (#4182). A source that is down narrows a search rather than failing it: the
// results come back with the list of who did not answer.
// SourceFailure is one source that could not be reached.
type SourceFailure struct {
Code string
Label string
Err error
}
// PartialError is a search that reached some sources and not others. It is
// returned alongside the results, not instead of them.
type PartialError struct {
Failures []SourceFailure
// Answered is how many sources did answer. Zero means the search failed.
Answered int
}
func (e *PartialError) Error() string {
if e == nil || len(e.Failures) == 0 {
return "some sources did not answer"
}
parts := make([]string, 0, len(e.Failures))
for _, f := range e.Failures {
parts = append(parts, fmt.Sprintf("%s: %s", f.Label, indexer.FirstLine(f.Err.Error())))
}
return "some sources did not answer — " + strings.Join(parts, "; ")
}
// Unwrap lets errors.Is find indexer.ErrUnreachable through a partial failure.
func (e *PartialError) Unwrap() []error {
if e == nil {
return nil
}
out := make([]error, 0, len(e.Failures))
for _, f := range e.Failures {
out = append(out, f.Err)
}
return out
}
// Fanout searches every source and merges what comes back. It is both the
// Searcher and the Fetcher, routing a .torrent fetch to the source that
// offered the result.
type Fanout struct {
// Sources in code order: the fold keeps a duplicate's first arrival, so
// the order decides which source a grab is fetched through.
Sources []Source
// Pace spaces each source's searches. Nil paces nothing.
Pace *Pacer
}
// Search asks every source concurrently. Each client bounds its own call by
// its configured timeout, so there is no shared deadline here.
func (f *Fanout) Search(ctx context.Context, query indexer.Query) ([]indexer.Result, error) {
if f == nil || len(f.Sources) == 0 {
return nil, indexer.ErrUnconfigured
}
found := make([][]indexer.Result, len(f.Sources))
errs := make([]error, len(f.Sources))
var wg sync.WaitGroup
for i, src := range f.Sources {
if src.Search == nil {
continue
}
f.Pace.spend(src)
wg.Add(1)
go func(i int, src Source) {
defer wg.Done()
found[i], errs[i] = src.Search.Search(ctx, query)
}(i, src)
}
wg.Wait()
out := make([]indexer.Result, 0, 32)
var failures []SourceFailure
answered, unconfigured := 0, 0
for i, src := range f.Sources {
if src.Search == nil {
continue
}
if errors.Is(errs[i], indexer.ErrUnconfigured) {
unconfigured++
continue
}
if rl, ok := indexer.AsRateLimited(errs[i]); ok {
f.Pace.hold(src.Code, rl)
}
if errs[i] != nil {
failures = append(failures, SourceFailure{Code: src.Code, Label: src.Label, Err: errs[i]})
continue
}
answered++
for j := range found[i] {
if found[i][j].Source == "" {
found[i][j].Source = src.Code
}
}
out = append(out, found[i]...)
}
switch {
case answered == 0 && len(failures) > 0:
return nil, &PartialError{Failures: failures}
case len(failures) > 0:
return out, &PartialError{Failures: failures, Answered: answered}
case answered == 0 && unconfigured > 0:
return nil, indexer.ErrUnconfigured
}
return out, nil
}
// Download fetches a result's .torrent from the source that offered it: a
// Prowlarr link carries Prowlarr's key and only Prowlarr can redeem it.
func (f *Fanout) Download(ctx context.Context, r indexer.Result) ([]byte, error) {
if f == nil {
return nil, indexer.ErrUnconfigured
}
for _, src := range f.Sources {
if src.Code != r.Source {
continue
}
if src.Fetch == nil {
return nil, fmt.Errorf("%w: %s cannot fetch torrent files", indexer.ErrUnconfigured, src.Label)
}
return src.Fetch.Download(ctx, r)
}
if r.Source == "" {
return nil, fmt.Errorf("%w: this result does not say which source it came from", indexer.ErrUnconfigured)
}
return nil, fmt.Errorf("%w: %q is no longer a configured source", indexer.ErrUnconfigured, r.Source)
}
// Narrowing is one source that did not answer, as a page shows it.
type Narrowing struct {
Source string `json:"source"`
Label string `json:"label"`
Reason string `json:"reason"`
}
// narrowings reads a PartialError out of err, and whether any source
// answered. Nil when err is not a partial failure.
func narrowings(err error) ([]Narrowing, bool) {
var partial *PartialError
if !errors.As(err, &partial) || len(partial.Failures) == 0 {
return nil, false
}
out := make([]Narrowing, 0, len(partial.Failures))
for _, f := range partial.Failures {
out = append(out, Narrowing{Source: f.Code, Label: f.Label, Reason: indexer.FirstLine(f.Err.Error())})
}
return out, partial.Answered > 0
}
+339
View File
@@ -0,0 +1,339 @@
package acquire
import (
"context"
"errors"
"path"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Taking a release (M502 #5521), ported from Fabled Librarian's grab.go and
// rivals.go. One live grab per want: a second is tie-broken against the
// download already running (FL #4470).
// Tag goes on every torrent Minstrel adds, so the operator can tell them
// from their own in the client.
const Tag = "minstrel"
// MissingClient is Taken.Missing when no download client is set up.
const MissingClient = "download_client"
// nearlyDone is how far along a running download stays whatever would
// replace it.
const nearlyDone = 0.9
// Downloader is the grab half of a Service.
type Downloader struct {
Client downloader.Driver
// Code is the client's acquisition_services row.
Code string
// Landing is the client's downloads folder as Minstrel sees it; each
// grab lands in a folder of its own under it.
Landing string
// Fetch gets a result's .torrent from the source that offered it.
Fetch Fetcher
}
// Origin says who took a release.
type Origin struct {
By string // OriginSchedule or OriginOperator
User pgtype.UUID
}
// Contest is how a grab fared against the download already running.
type Contest struct {
// Kept: the running one stays and this grab was not made. Replaced:
// this grab took its place.
Kept, Replaced bool
Reason string
Rival dbq.Grab
}
// Taken is what came of a grab.
type Taken struct {
Chosen *Candidate
// Added is the only field that means a download started.
Added bool
SavePath string
Grab dbq.Grab
// Gone: the search no longer offers the release.
Gone bool
// Refused: not taken; the reason is on Chosen.
Refused bool
// Unconfigured: Missing names what is not set up ("" for search).
Unconfigured bool
Missing string
Contest *Contest
Error string
}
// Take searches again and grabs the candidate named by key: a person's
// grab. Searching again keeps the indexer's download link (which can carry
// its key) on the server, and finds a release gone since it was shown.
// force takes what the verdict would not.
func (s *Service) Take(ctx context.Context, want Want, key string, force bool, origin Origin) Taken {
if s.Grabber == nil {
return Taken{Unconfigured: true, Missing: MissingClient}
}
found := s.For(ctx, want)
switch {
case found.Unconfigured:
return Taken{Unconfigured: true}
case found.Error != "":
return Taken{Error: found.Error}
}
chosen := pick(found.Candidates, key)
if chosen == nil {
return Taken{Gone: true}
}
return s.Grab(ctx, want, chosen, found.Candidates, force, origin)
}
// Grab hands a judged candidate to the download client. The schedule calls
// it with the candidate it has just judged; Take calls it after searching
// again.
func (s *Service) Grab(ctx context.Context, want Want, chosen *Candidate, all []Candidate, force bool, origin Origin) Taken {
out := Taken{Chosen: chosen}
if s.Grabber == nil || s.Grabber.Client == nil {
out.Unconfigured, out.Missing = true, MissingClient
return out
}
if chosen.Verdict == Blocked || (chosen.Verdict != Grab && !force) {
out.Refused = true
return out
}
unlock := lockWant(want.ID)
defer unlock()
contest := s.contest(ctx, want, chosen, all, origin)
if contest != nil && contest.Kept {
out.Contest = contest
return out
}
add := downloader.Add{Magnet: chosen.Source.Magnet, Tags: []string{Tag}}
hash := chosen.InfoHash
if add.Magnet == "" {
if s.Grabber.Fetch == nil {
out.Error = "This release is a .torrent link, and nothing is set up to fetch it."
return out
}
body, err := s.Grabber.Fetch.Download(ctx, chosen.Source)
if magnet, ok := indexer.AsMagnet(err); ok {
add.Magnet, err = magnet, nil
}
if err != nil {
out.Error = indexer.FirstLine(err.Error())
return out
}
if add.Magnet == "" {
add.Torrent = body
if meta, err := release.ParseTorrent(body); err == nil {
if hash == "" {
hash = meta.InfoHash
}
if refused := s.judgeFiles(ctx, want, chosen, hash, meta.Files, force); refused {
out.Refused = true
return out
}
}
}
}
if hash == "" && add.Magnet != "" {
hash = indexer.MagnetHash(add.Magnet)
}
add.SavePath = landingFor(s.Grabber.Landing, hash, chosen.Name)
err := s.Grabber.Client.AddTorrent(ctx, add)
if errors.Is(err, downloader.ErrDuplicate) && s.heldElsewhere(ctx, hash) {
why := "Already in the download client outside Minstrel's category, where Minstrel cannot follow it (" +
strings.TrimPrefix(indexer.FirstLine(err.Error()), downloader.ErrDuplicate.Error()+": ") + ")"
if force {
out.Error = why + "."
return out
}
set(chosen, Blocked, Hard, CodeBlocked, why+".")
if s.Memory != nil && hash != "" {
_ = s.Memory.Block(ctx, dbq.BlockReleaseParams{
InfoHash: hash, Name: chosen.Name, Reason: BlockGone, Detail: why + ".", WantID: want.ID,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(GoneBlock), Valid: true},
})
}
out.Refused = true
return out
}
if err != nil && !errors.Is(err, downloader.ErrDuplicate) {
if errors.Is(err, downloader.ErrUnconfigured) {
out.Unconfigured, out.Missing = true, MissingClient
} else {
out.Error = indexer.FirstLine(err.Error())
}
return out
}
out.Added, out.SavePath = true, add.SavePath
if s.Memory == nil {
return out
}
var replaced pgtype.UUID
why := ""
if contest != nil && contest.Replaced {
out.Contest = contest
replaced, why = contest.Rival.ID, "Replaced by "+chosen.Name+": "+contest.Reason+"."
}
g, err := s.Memory.Grabbed(ctx, dbq.InsertGrabParams{
WantID: want.ID, InfoHash: hash, Name: chosen.Name, Key: Key(*chosen), Indexer: chosen.Indexer,
Source: chosen.Source.Source, Client: s.Grabber.Code, SavePath: add.SavePath,
Quality: string(chosen.Tier), Origin: origin.By, Forced: force, GrabbedBy: origin.User,
}, replaced, why)
if err != nil {
out.Error = "The download started, but could not be recorded: " + indexer.FirstLine(err.Error())
return out
}
out.Grab = g
// Removed only after the new download was added and recorded, so a
// failure never leaves the want with nothing running.
if replaced.Valid && contest.Rival.InfoHash != "" {
if err := s.Grabber.Client.Delete(ctx, true, contest.Rival.InfoHash); err != nil {
contest.Reason += ". The download it replaced could not be removed from the client: " + indexer.FirstLine(err.Error())
}
}
return out
}
// heldElsewhere reports whether a release the client says it already holds is
// one it does not list for Minstrel: held under another category, from before
// Minstrel took the downloads over (#6176). Recorded as a grab, it would read
// as gone half an hour later and be taken again, every pass. A client that
// cannot answer now is given the benefit of the doubt, as the add was.
func (s *Service) heldElsewhere(ctx context.Context, hash string) bool {
if hash == "" {
return false
}
listed, err := s.Grabber.Client.Torrents(ctx, indexer.NormalizeHash(hash))
return err == nil && len(listed) == 0
}
// judgeFiles judges a chosen release again on its .torrent's file list, and
// sets it aside when the files say it is not the album, so no later search
// offers it again. force still takes it.
func (s *Service) judgeFiles(ctx context.Context, want Want, chosen *Candidate, hash string, files []release.File, force bool) bool {
chosen.Files = files
if chosen.FilesCount == 0 {
chosen.FilesCount = len(files)
}
veto, reason, _ := release.JudgeFiles(want.Want, files)
if veto == "" || force {
return false
}
set(chosen, Rejected, Hard, veto, reason)
if s.Memory != nil && hash != "" {
_ = s.Memory.Block(ctx, dbq.BlockReleaseParams{
InfoHash: hash, Name: chosen.Name, Reason: BlockFiles, Detail: reason, WantID: want.ID,
})
}
return true
}
var wantLocks sync.Map // want id → *sync.Mutex
// lockWant settles two grabs for one want one after the other. The
// one-live-grab index is the backstop for anything outside this process.
func lockWant(id pgtype.UUID) func() {
if !id.Valid {
return func() {}
}
m, _ := wantLocks.LoadOrStore(id.Bytes, &sync.Mutex{})
mu := m.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// contest settles a grab against the download running for its want: a
// person's grab beats the schedule's; between two of a kind the better
// candidate wins; a tie, a download nearly done, or one that cannot be
// removed stays. Nil when nothing is running.
func (s *Service) contest(ctx context.Context, want Want, chosen *Candidate, all []Candidate, origin Origin) *Contest {
if s.Memory == nil || !want.ID.Valid {
return nil
}
running, ok, err := s.Memory.LiveGrab(ctx, want.ID)
if err != nil || !ok {
return nil
}
c := &Contest{Rival: running}
if running.InfoHash != "" && running.InfoHash == chosen.InfoHash {
c.Kept, c.Reason = true, "That release is already downloading for this album."
return c
}
switch {
case origin.By == OriginOperator && running.Origin == OriginSchedule:
c.Reason = "you chose it over the one the schedule started"
case origin.By == OriginSchedule && running.Origin == OriginOperator:
c.Kept, c.Reason = true, "A download you started for this album is already running."
return c
default:
var theirs *Candidate
for i := range all {
if (running.InfoHash != "" && all[i].InfoHash == running.InfoHash) || Key(all[i]) == running.Key {
theirs = &all[i]
break
}
}
if theirs == nil || !better(*chosen, *theirs) {
c.Kept, c.Reason = true, "A download for this album is already running, and this release is no better."
return c
}
c.Reason = "it ranks above the one that was running"
}
if running.InfoHash == "" {
c.Kept, c.Reason = true, "A download for this album is already running, and it cannot be identified to replace it."
return c
}
if ts, err := s.Grabber.Client.Torrents(ctx, running.InfoHash); err == nil && len(ts) > 0 && ts[0].Progress >= nearlyDone {
c.Kept, c.Reason = true, "A download for this album is already running and nearly done."
return c
}
c.Replaced = true
return c
}
// landingFor is the folder a download lands in: one per torrent, named by
// its infohash, so two grabs never unpack into the same place.
func landingFor(root, hash, name string) string {
dir := hash
if dir == "" {
dir = slug(name)
}
if dir == "" {
dir = "unnamed"
}
return path.Join(root, dir)
}
// slug keeps a folder name to characters every filesystem in the chain
// takes.
func slug(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
if b.Len() >= 80 {
break
}
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_', r == ' ', r == '.':
b.WriteRune('-')
}
}
return strings.Trim(b.String(), "-")
}
+199
View File
@@ -0,0 +1,199 @@
package acquire
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Block reasons, matching release_blocks_reason_check.
const (
BlockOperator = "operator"
BlockFiles = "files"
// BlockImport: the import found its files were not the album (#5522).
BlockImport = "import"
// BlockStalled: given up on because it would not finish (#5523); held
// back until the block's expires_at.
BlockStalled = "stalled"
// BlockGone: its download client would not let Minstrel follow it, held
// outside Minstrel's category or leaving the client twice (#6176); held
// back for GoneBlock.
BlockGone = "gone"
)
// GoneBlock is how long a release its client lost track of is held back:
// long enough that a release which keeps vanishing is tried once a day rather
// than on every pass, short enough that it is taken once the client lets go.
const GoneBlock = 24 * time.Hour
// Grab origins, matching grabs_origin_check.
const (
OriginSchedule = "schedule"
OriginOperator = "operator"
)
// Memory is what searches and grabs leave behind: every candidate of a
// want's last search, the grabs, and the releases set aside.
type Memory struct {
pool *pgxpool.Pool
}
// NewMemory returns a Memory over pool.
func NewMemory(pool *pgxpool.Pool) *Memory { return &Memory{pool: pool} }
// Keep replaces a want's candidates with this search's, in order.
func (m *Memory) Keep(ctx context.Context, wantID pgtype.UUID, cands []Candidate) error {
return pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
if err := q.DeleteWantCandidates(ctx, wantID); err != nil {
return err
}
for i, c := range cands {
reasons, err := json.Marshal(c.Reasons)
if err != nil {
return err
}
if c.Reasons == nil {
reasons = []byte("[]")
}
folded := c.FoldedFrom
if folded == nil {
folded = []string{}
}
if err := q.InsertWantCandidate(ctx, dbq.InsertWantCandidateParams{
WantID: wantID, Key: Key(c), Name: c.Name, InfoHash: c.InfoHash, Indexer: c.Indexer,
Source: c.Source.Source, SizeBytes: c.SizeBytes, Seeders: int32(c.Seeders),
FilesCount: int32(c.FilesCount), Format: release.Describe(c.Release), Quality: string(c.Tier),
Year: int32(c.Year), Verdict: string(c.Verdict), Reason: c.Reason, Reasons: reasons,
FoldedFrom: folded, Rank: int32(i),
}); err != nil {
return err
}
}
return nil
})
}
// blocked is every release set aside, by infohash, with the sentence a
// candidate shows. A failed read blocks nothing: offering a release set
// aside costs one grab a person can undo, while blocking everything on a
// database blip would hide every result.
func (m *Memory) blocked(ctx context.Context) map[string]string {
if m == nil {
return nil
}
rows, err := dbq.New(m.pool).ListReleaseBlocks(ctx)
if err != nil {
return nil
}
out := make(map[string]string, len(rows))
for _, r := range rows {
out[indexer.NormalizeHash(r.InfoHash)] = BlockSentence(r.Reason, r.Detail)
}
return out
}
// BlockSentence is what a blocked candidate says.
func BlockSentence(reason, detail string) string {
switch reason {
case BlockFiles:
if detail != "" {
return "Set aside for what its files hold: " + detail
}
return "Set aside for what its files hold."
case BlockImport:
if detail != "" {
return "Refused on import: " + detail
}
return "Refused on import: its files were not the album."
case BlockGone:
if detail != "" {
return "Held back for now: " + detail
}
return "Held back for now: its download client lost track of it."
case BlockStalled:
if detail != "" {
return "Given up on for now: " + detail
}
return "Given up on for now: it would not finish."
}
return "You set this release aside."
}
// Block sets a release aside and marks every kept candidate of it blocked.
func (m *Memory) Block(ctx context.Context, p dbq.BlockReleaseParams) error {
p.InfoHash = indexer.NormalizeHash(p.InfoHash)
if p.InfoHash == "" {
return errors.New("a release without an infohash cannot be set aside")
}
return pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
if err := q.BlockRelease(ctx, p); err != nil {
return err
}
return q.MarkCandidatesBlocked(ctx, dbq.MarkCandidatesBlockedParams{
InfoHash: p.InfoHash, Reason: BlockSentence(p.Reason, p.Detail),
})
})
}
// Unblock lets a release be taken again. Its kept candidates wait for a
// person until the next search judges them afresh.
func (m *Memory) Unblock(ctx context.Context, hash string) (bool, error) {
hash = indexer.NormalizeHash(hash)
var n int64
err := pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
var err error
if n, err = q.UnblockRelease(ctx, hash); err != nil {
return err
}
return q.MarkCandidatesUnblocked(ctx, dbq.MarkCandidatesUnblockedParams{
InfoHash: hash, Reason: "Allowed again. The next search judges it afresh.",
})
})
return n > 0, err
}
// LiveGrab is the download running for a want, if there is one.
func (m *Memory) LiveGrab(ctx context.Context, wantID pgtype.UUID) (dbq.Grab, bool, error) {
g, err := dbq.New(m.pool).GetLiveGrab(ctx, wantID)
if errors.Is(err, pgx.ErrNoRows) {
return g, false, nil
}
if err != nil {
return g, false, err
}
return g, true, nil
}
// Grabbed records a download started, ending the grab it replaced first so
// the one-live-grab index holds.
func (m *Memory) Grabbed(ctx context.Context, p dbq.InsertGrabParams, replaced pgtype.UUID, why string) (dbq.Grab, error) {
var g dbq.Grab
err := pgx.BeginFunc(ctx, m.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
if replaced.Valid {
if err := q.EndGrab(ctx, dbq.EndGrabParams{ID: replaced, EndReason: "superseded", EndDetail: why}); err != nil {
return err
}
}
var err error
g, err = q.InsertGrab(ctx, p)
return err
})
if err != nil {
return g, fmt.Errorf("record grab: %w", err)
}
return g, nil
}
+145
View File
@@ -0,0 +1,145 @@
package acquire
import (
"context"
"sync"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
// SearchPace is the one Pacer every search shares, ported from Fabled
// Librarian (#4448). Process-wide because a Fanout is built fresh for each
// search; a Pacer per Fanout would start empty and pace nothing.
var SearchPace = &Pacer{}
// holdWithoutRetryAfter is how long a source that answered 429 without saying
// for how long is left alone: one schedule tick.
const holdWithoutRetryAfter = 10 * time.Minute
// Pacer spaces the searches sent to each source by its searches_per_hour: a
// spacing, not a burst bucket, because Prowlarr answers 429 to a few requests
// in quick succession (FL #4304). Every search spends, the schedule's and a
// person's alike; only the schedule waits.
type Pacer struct {
// Now and Sleep are the clock, replaceable in tests.
Now func() time.Time
Sleep func(context.Context, time.Duration) error
mu sync.Mutex
next map[string]time.Time
}
func (p *Pacer) now() time.Time {
if p.Now != nil {
return p.Now()
}
return time.Now()
}
func (p *Pacer) sleep(ctx context.Context, d time.Duration) error {
if p.Sleep != nil {
return p.Sleep(ctx, d)
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// gap is the spacing a source's number asks for; zero is unpaced.
func gap(perHour int) time.Duration {
if perHour <= 0 {
return 0
}
return time.Hour / time.Duration(perHour)
}
// readyAt is when every source may next be asked.
func (p *Pacer) readyAt(sources []Source) time.Time {
p.mu.Lock()
defer p.mu.Unlock()
var at time.Time
for _, s := range sources {
if s.Search == nil {
continue
}
if n := p.next[s.Code]; n.After(at) {
at = n
}
}
return at
}
// Wait holds until every source may be asked, and answers false, without
// waiting, when that is after the deadline or ctx ends first. All of them,
// not any: a search while one is held back would be narrowed, and the want
// recorded as looked for.
func (p *Pacer) Wait(ctx context.Context, sources []Source, deadline time.Time) bool {
if p == nil {
return true
}
at := p.readyAt(sources)
if !deadline.IsZero() && at.After(deadline) {
return false
}
if d := at.Sub(p.now()); d > 0 {
return p.sleep(ctx, d) == nil
}
return ctx.Err() == nil
}
// spend takes one search's slot from a source.
func (p *Pacer) spend(s Source) {
g := gap(s.PerHour)
if p == nil || g == 0 {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if p.next == nil {
p.next = map[string]time.Time{}
}
at := p.next[s.Code]
if t := p.now(); at.Before(t) {
at = t
}
p.next[s.Code] = at.Add(g)
}
// hold leaves a source alone after it answered 429, for as long as it asked,
// whatever its searches_per_hour says.
func (p *Pacer) hold(code string, rl *indexer.RateLimited) {
if p == nil {
return
}
d := rl.RetryAfter
if d <= 0 {
d = holdWithoutRetryAfter
}
p.mu.Lock()
defer p.mu.Unlock()
if p.next == nil {
p.next = map[string]time.Time{}
}
if until := p.now().Add(d); until.After(p.next[code]) {
p.next[code] = until
}
}
// Ready holds until every source this service searches may be asked again,
// and answers false when that is past the deadline. The schedule calls it
// before each want; a person's search never does.
func (s *Service) Ready(ctx context.Context, deadline time.Time) bool {
if s == nil {
return ctx.Err() == nil
}
if f, ok := s.Search.(*Fanout); ok && f != nil {
return f.Pace.Wait(ctx, f.Sources, deadline)
}
return ctx.Err() == nil
}
+408
View File
@@ -0,0 +1,408 @@
package acquire
import (
"context"
"errors"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// One search for one want, and what could be done about each result (M502
// #5521), ported from Fabled Librarian's internal/acquire. internal/release
// says whether a name is the album and what quality it is; this package
// asks in the right order and says why in words a person can act on.
// Verdict is what can be done about one result.
type Verdict string
const (
// Grab: the album, in a quality the profile takes.
Grab Verdict = "grab"
// Review: maybe the album; a person decides.
Review Verdict = "review"
// Refused: the album, in a quality the profile does not take. The only
// verdict that names a setting.
Refused Verdict = "refused"
// Rejected: not the album.
Rejected Verdict = "rejected"
// Blocked: a release set aside, by a person or by its files.
Blocked Verdict = "blocked"
)
// order sorts verdicts: what can be taken first, what was given up on last.
var order = map[Verdict]int{Grab: 0, Review: 1, Refused: 2, Rejected: 3, Blocked: 4}
// Reason kinds: a hard reason is a fact about the bytes, a soft one about the
// moment or a setting.
const (
Hard = "hard"
Soft = "soft"
)
// Reason codes beyond internal/release's vetoes.
const (
CodeBlocked = "blocked"
CodeProfile = "profile"
CodeNoSeeders = "no_seeders"
CodeDoubt = "doubt"
// CodeNotBetter: the album, but no better than the copy the library
// holds, for a want that is an upgrade (#5524).
CodeNotBetter = "not_better"
)
// Reason is one thing a verdict rests on.
type Reason struct {
Kind string `json:"kind"`
Code string `json:"code"`
Text string `json:"text"`
}
// Want is the album searched for: its wants row and how the catalogue
// describes it.
type Want struct {
ID pgtype.UUID
release.Want
// Floor is the tier of the copy the library holds, for an album wanted
// for a better one (#5524): a release must rank above it. "" for an
// album not held, and for a video rip, which any clean copy beats.
Floor release.Tier
// Held says the floor in words: "MP3, about 192 kbps".
Held string
}
// Candidate is one result with everything decided about it.
type Candidate struct {
release.Release
// Source is the indexer's own result. It never leaves the server: its
// DownloadURL can carry the indexer's key.
Source indexer.Result
InfoHash string
Indexer string
SizeBytes int64
Seeders int
SwarmKnown bool
Category int
FilesCount int
// Files is the torrent's file list once its .torrent has been read.
Files []release.File
FoldedFrom []string
Tier release.Tier
YearAgrees bool
Verdict Verdict
Reason string
Reasons []Reason
}
// Key is how a candidate is named across a search and a grab: its infohash,
// or its name when the source gave none.
func Key(c Candidate) string {
if c.InfoHash != "" {
return c.InfoHash
}
return c.Name
}
// Result is one search.
type Result struct {
Query string
// Unconfigured: no search source is set up.
Unconfigured bool
// Error is a search that failed outright.
Error string
// Narrowed names sources that did not answer on a search that otherwise
// did.
Narrowed []Narrowing
Considered int
Folded int
// Looked is how many .torrent files were read for their file lists.
Looked int
Candidates []Candidate
// KeepError: the candidates could not be stored.
KeepError string
}
// Grabbable is what may be taken, best first.
func (r Result) Grabbable() []Candidate {
var out []Candidate
for _, c := range r.Candidates {
if c.Verdict == Grab {
out = append(out, c)
}
}
return out
}
// ForAPerson says whether anything found is worth a person's look: a
// release that may be the album, whatever its quality.
func (r Result) ForAPerson() bool {
for _, c := range r.Candidates {
if c.Verdict == Grab || c.Verdict == Review || c.Verdict == Refused {
return true
}
}
return false
}
// Service searches and grabs.
type Service struct {
// Search is nil when no source is configured.
Search Searcher
// Grabber is nil when no download client is configured.
Grabber *Downloader
Profile release.Profile
// Memory stores candidates, grabs and blocks. Nil keeps nothing.
Memory *Memory
}
// Query is the text sent to the indexers: the artist and the title, without
// a trailing bracketed part the release names usually leave off.
func Query(w release.Want) string {
title := w.Title
if i := strings.IndexAny(title, "(["); i > 0 {
title = title[:i]
}
return strings.Join(strings.Fields(strings.TrimSpace(w.Artist)+" "+strings.TrimSpace(title)), " ")
}
// For searches for one want and judges everything that comes back. It never
// returns an error: a search that could not be made is a Result saying so.
func (s *Service) For(ctx context.Context, want Want) Result {
r := Result{Query: Query(want.Want)}
if s == nil || s.Search == nil {
r.Unconfigured = true
return r
}
found, err := s.Search.Search(ctx, indexer.Query{Text: r.Query, Categories: indexer.MusicCategories})
// A partial search arrives as results and an error together.
if narrowed, answered := narrowings(err); narrowed != nil && answered {
r.Narrowed, err = narrowed, nil
}
switch {
case errors.Is(err, indexer.ErrUnconfigured):
r.Unconfigured = true
return r
case err != nil:
r.Error = indexer.FirstLine(err.Error())
return r
}
r.Considered = len(found)
cands, folded := fold(found)
r.Folded = folded
blocked := s.Memory.blocked(ctx)
for i := range cands {
decide(&cands[i], want, s.Profile, blocked)
}
sortCandidates(cands)
for _, i := range s.peek(ctx, cands) {
decide(&cands[i], want, s.Profile, blocked)
r.Looked++
}
sortCandidates(cands)
r.Candidates = cands
if s.Memory != nil && want.ID.Valid {
if err := s.Memory.Keep(ctx, want.ID, cands); err != nil {
r.KeepError = indexer.FirstLine(err.Error())
}
}
return r
}
// decide works out one candidate's verdict, in order: given up on, then
// whether it is the album, then whether the profile takes its quality, then
// whether anything leaves it to a person. A quality refusal said about the
// wrong album would be noise, so identity comes first.
func decide(c *Candidate, want Want, profile release.Profile, blocked map[string]string) {
w := want.Want
c.Verdict, c.Reason, c.Reasons = "", "", nil
if why, ok := blocked[c.InfoHash]; ok && c.InfoHash != "" {
set(c, Blocked, Hard, CodeBlocked, why)
return
}
m := release.Judge(w, c.Release)
c.YearAgrees = m.YearAgrees
if m.Veto != "" {
set(c, Rejected, Hard, m.Veto, m.Reason)
return
}
doubt := m.Doubt
if len(c.Files) > 0 {
if in := release.Inspect(c.Files); in.Format != "" {
c.Format = in.Format
}
veto, reason, fileDoubt := release.JudgeFiles(w, c.Files)
if veto != "" {
set(c, Rejected, Hard, veto, reason)
return
}
if doubt == "" {
doubt = fileDoubt
}
}
c.Tier = release.TierOf(c.Release, c.Category)
if !profile.Allows(c.Tier) {
set(c, Refused, Soft, CodeProfile, "Your quality settings don’t take "+strings.ToLower(release.Label(c.Tier))+".")
return
}
if want.Floor != "" && release.Rank(c.Tier) <= release.Rank(want.Floor) {
held := want.Held
if held == "" {
held = strings.ToLower(release.Label(want.Floor))
}
set(c, Refused, Soft, CodeNotBetter, "No better than the copy you have ("+held+").")
return
}
if doubt != "" {
set(c, Review, Soft, CodeDoubt, doubt)
return
}
if c.Seeders == 0 {
set(c, Review, Soft, CodeNoSeeders, "Nobody is sharing it right now.")
return
}
c.Verdict = Grab
c.Reason = "The album, " + strings.ToLower(release.Label(c.Tier)) + "."
if d := release.Describe(c.Release); d != "" {
c.Reason = "The album, " + d + "."
}
}
func set(c *Candidate, v Verdict, kind, code, text string) {
c.Verdict, c.Reason = v, text
c.Reasons = append(c.Reasons, Reason{Kind: kind, Code: code, Text: text})
}
// sortCandidates puts the best first, stably so the same search proposes the
// same grab twice.
func sortCandidates(cands []Candidate) {
sort.SliceStable(cands, func(a, b int) bool { return better(cands[a], cands[b]) })
}
// better says whether x ranks above y: verdict, then quality, then how many
// share it, then whether its year agrees.
func better(x, y Candidate) bool {
if order[x.Verdict] != order[y.Verdict] {
return order[x.Verdict] < order[y.Verdict]
}
if rx, ry := release.Rank(x.Tier), release.Rank(y.Tier); rx != ry {
return rx > ry
}
if x.Seeders != y.Seeders {
return x.Seeders > y.Seeders
}
return x.YearAgrees && !y.YearAgrees
}
// fold merges results that are the same torrent, by infohash. Prowlarr
// aggregates, so one release arrives once per tracker that lists it. A
// result without a hash is never folded: an absent hash is not a shared one.
func fold(found []indexer.Result) ([]Candidate, int) {
out := make([]Candidate, 0, len(found))
at := make(map[string]int, len(found))
folded := 0
for _, f := range found {
if strings.TrimSpace(f.Title) == "" {
continue
}
hash := indexer.NormalizeHash(f.InfoHash)
if hash == "" && f.Magnet != "" {
hash = indexer.MagnetHash(f.Magnet)
}
if hash != "" {
if i, seen := at[hash]; seen {
folded++
if f.Indexer != "" {
out[i].FoldedFrom = append(out[i].FoldedFrom, f.Indexer)
}
// A measured swarm beats a reported one; among like, the
// highest count.
switch {
case f.SwarmKnown && !out[i].SwarmKnown:
out[i].Seeders, out[i].SwarmKnown = f.Seeders, true
case f.SwarmKnown == out[i].SwarmKnown && f.Seeders > out[i].Seeders:
out[i].Seeders = f.Seeders
}
if f.FilesCount > 0 && out[i].FilesCount == 0 {
out[i].FilesCount = f.FilesCount
}
continue
}
at[hash] = len(out)
}
out = append(out, Candidate{
Release: release.Parse(f.Title), Source: f, InfoHash: hash, Indexer: f.Indexer,
SizeBytes: f.SizeBytes, Seeders: f.Seeders, SwarmKnown: f.SwarmKnown,
Category: f.Category, FilesCount: f.FilesCount,
})
}
return out, folded
}
// maxPeeks is how many .torrent files one search reads. Few: each goes
// through Prowlarr to a tracker, and Prowlarr answers 429 to a burst.
const maxPeeks = 3
// peek reads the .torrent of candidates that are the album but whose name
// states no format, so their files can say what they are; it returns which
// now carry a file list. A failed fetch leaves the candidate as it was.
func (s *Service) peek(ctx context.Context, cands []Candidate) []int {
f, ok := s.Search.(Fetcher)
if !ok {
return nil
}
var looked []int
for i := range cands {
if len(looked) >= maxPeeks || ctx.Err() != nil {
break
}
c := &cands[i]
if c.Tier != release.TierUnknown || (c.Verdict != Grab && c.Verdict != Refused) ||
c.Files != nil || c.Source.DownloadURL == "" || c.Source.Magnet != "" {
continue
}
body, err := f.Download(ctx, c.Source)
if err != nil {
continue
}
meta, err := release.ParseTorrent(body)
if err != nil {
continue
}
c.Files = meta.Files
if c.InfoHash == "" {
c.InfoHash = meta.InfoHash
}
if c.FilesCount == 0 {
c.FilesCount = len(meta.Files)
}
looked = append(looked, i)
}
return looked
}
// pick finds a candidate by key: the infohash first, the exact name for a
// result that has none.
func pick(cands []Candidate, key string) *Candidate {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
hash := indexer.NormalizeHash(key)
for i := range cands {
if cands[i].InfoHash != "" && cands[i].InfoHash == hash {
return &cands[i]
}
}
for i := range cands {
if cands[i].InfoHash == "" && cands[i].Name == key {
return &cands[i]
}
}
return nil
}
+291
View File
@@ -0,0 +1,291 @@
package acquire
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
type fakeSearch struct {
results []indexer.Result
err error
asked []indexer.Query
}
func (f *fakeSearch) Search(_ context.Context, q indexer.Query) ([]indexer.Result, error) {
f.asked = append(f.asked, q)
return f.results, f.err
}
var ram = Want{Want: release.Want{
Artist: "Daft Punk", Title: "Random Access Memories", Year: 2013, PrimaryType: "Album",
MinTracks: 13, MaxTracks: 15,
}}
var anyQuality = release.NewProfile([]string{
"lossless_hires", "lossless", "lossy_high", "lossy_standard", "lossy_low", "lossy_poor", "lossy_trash",
})
func result(title, hash string, seeders int) indexer.Result {
return indexer.Result{Title: title, InfoHash: hash, Seeders: seeders, Indexer: "Knaben", Source: "prowlarr",
Magnet: "magnet:?xt=urn:btih:" + hash}
}
func TestFor_JudgesSortsAndFolds(t *testing.T) {
search := &fakeSearch{results: []indexer.Result{
result("Daft Punk - Random Access Memories (2013) [MP3 320]", "aaaa", 40),
result("Daft Punk Random Access Memories (Drumless Edition 2023) [Flac 24-88]", "bbbb", 90),
result("Daft Punk - Random Access Memories (2013) [FLAC]", "cccc", 12),
result("Daft Punk - Random Access Memories (2013) [FLAC]", "CCCC", 30), // the same torrent from another tracker
result("Daft Punk - Discovery (2001) [FLAC]", "dddd", 50),
}}
s := &Service{Search: search, Profile: anyQuality}
r := s.For(context.Background(), ram)
if len(search.asked) != 1 || search.asked[0].Text != "Daft Punk Random Access Memories" {
t.Fatalf("asked %+v", search.asked)
}
if r.Considered != 5 || r.Folded != 1 || len(r.Candidates) != 4 {
t.Fatalf("considered %d folded %d candidates %d", r.Considered, r.Folded, len(r.Candidates))
}
// Lossless before high-quality lossy among what can be taken; the
// folded copy keeps the higher seeder count.
first, second := r.Candidates[0], r.Candidates[1]
if first.InfoHash != "cccc" || first.Verdict != Grab || first.Seeders != 30 || first.Tier != release.TierLossless {
t.Errorf("first = %s %s %d %s", first.Name, first.Verdict, first.Seeders, first.Tier)
}
if second.InfoHash != "aaaa" || second.Verdict != Grab {
t.Errorf("second = %s %s", second.Name, second.Verdict)
}
for _, c := range r.Candidates[2:] {
if c.Verdict != Rejected || len(c.Reasons) != 1 || c.Reasons[0].Kind != Hard {
t.Errorf("%s: %s %+v", c.Name, c.Verdict, c.Reasons)
}
}
if len(r.Grabbable()) != 2 || !r.ForAPerson() {
t.Errorf("grabbable %d", len(r.Grabbable()))
}
}
func TestFor_ProfileSeedersAndDoubt(t *testing.T) {
search := &fakeSearch{results: []indexer.Result{
result("Daft Punk - Random Access Memories [MP3 128]", "aaaa", 5),
result("Daft Punk - Random Access Memories [FLAC]", "bbbb", 0),
result("Daft Punk - Random Access Memories", "cccc", 9),
}}
s := &Service{Search: search, Profile: release.NewProfile([]string{"lossless", "lossy_high"})}
r := s.For(context.Background(), ram)
verdicts := map[string]Verdict{}
for _, c := range r.Candidates {
verdicts[c.InfoHash] = c.Verdict
}
if verdicts["aaaa"] != Refused || verdicts["bbbb"] != Review || verdicts["cccc"] != Refused {
t.Errorf("verdicts = %v", verdicts)
}
if len(r.Grabbable()) != 0 || !r.ForAPerson() {
t.Error("nothing grabbable, but something for a person")
}
}
func TestFor_BlockedReleasesAreSetAside(t *testing.T) {
c := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [FLAC]"), InfoHash: "aaaa", Seeders: 3}
decide(&c, ram, anyQuality, map[string]string{"aaaa": "You set this release aside."})
if c.Verdict != Blocked || c.Reason != "You set this release aside." {
t.Errorf("verdict %s %q", c.Verdict, c.Reason)
}
}
func TestFor_UnconfiguredAndFailedSearches(t *testing.T) {
if r := (&Service{}).For(context.Background(), ram); !r.Unconfigured {
t.Error("no source is unconfigured")
}
r := (&Service{Search: &fakeSearch{err: indexer.ErrUnconfigured}}).For(context.Background(), ram)
if !r.Unconfigured {
t.Error("ErrUnconfigured is unconfigured")
}
r = (&Service{Search: &fakeSearch{err: errors.New("dial tcp: refused")}}).For(context.Background(), ram)
if r.Error == "" {
t.Error("a failed search says so")
}
}
func TestFanout_ANarrowedSearchKeepsWhatAnswered(t *testing.T) {
up := &fakeSearch{results: []indexer.Result{result("Daft Punk - Random Access Memories [FLAC]", "aaaa", 3)}}
down := &fakeSearch{err: indexer.ErrUnreachable}
f := &Fanout{Sources: []Source{{Code: "prowlarr", Label: "Prowlarr", Search: up}, {Code: "torznab", Label: "Feed", Search: down}}}
r := (&Service{Search: f, Profile: anyQuality}).For(context.Background(), ram)
if r.Error != "" || len(r.Candidates) != 1 || len(r.Narrowed) != 1 || r.Narrowed[0].Source != "torznab" {
t.Errorf("narrowed search: error %q, %d candidates, narrowed %+v", r.Error, len(r.Candidates), r.Narrowed)
}
f.Sources = f.Sources[1:]
if r := (&Service{Search: f}).For(context.Background(), ram); r.Error == "" {
t.Error("every source down is a failed search")
}
}
func TestPacer_SpacesSearchesAndHoldsAfterA429(t *testing.T) {
now := time.Unix(0, 0)
var slept time.Duration
p := &Pacer{Now: func() time.Time { return now }, Sleep: func(_ context.Context, d time.Duration) error { slept += d; return nil }}
src := []Source{{Code: "prowlarr", PerHour: 120, Search: &fakeSearch{}}}
p.spend(src[0])
if !p.Wait(context.Background(), src, now.Add(time.Hour)) || slept != 30*time.Second {
t.Errorf("slept %s, want 30s", slept)
}
if p.Wait(context.Background(), src, now.Add(time.Second)) {
t.Error("waited past the deadline")
}
p.hold("prowlarr", &indexer.RateLimited{RetryAfter: 5 * time.Minute})
if at := p.readyAt(src); at.Sub(now) != 5*time.Minute {
t.Errorf("held until %s", at.Sub(now))
}
}
type fakeClient struct {
downloader.Driver
added []downloader.Add
addErr error
deleted []string
progress float64
// elsewhere: the client holds it but does not list it for Minstrel.
elsewhere bool
}
func (c *fakeClient) AddTorrent(_ context.Context, a downloader.Add) error {
c.added = append(c.added, a)
return c.addErr
}
func (c *fakeClient) Torrents(_ context.Context, hashes ...string) ([]downloader.Torrent, error) {
if c.elsewhere {
return nil, nil
}
return []downloader.Torrent{{Hash: hashes[0], Progress: c.progress}}, nil
}
func (c *fakeClient) Delete(_ context.Context, _ bool, hashes ...string) error {
c.deleted = append(c.deleted, hashes...)
return nil
}
func TestGrab_AddsAMagnetToItsOwnFolder(t *testing.T) {
client := &fakeClient{}
s := &Service{Search: &fakeSearch{results: []indexer.Result{
result("Daft Punk - Random Access Memories (2013) [FLAC]", "ABCD", 8),
}}, Profile: anyQuality, Grabber: &Downloader{Client: client, Code: "qbittorrent", Landing: "/downloads"}}
r := s.For(context.Background(), ram)
took := s.Grab(context.Background(), ram, &r.Candidates[0], r.Candidates, false, Origin{By: OriginSchedule})
if !took.Added || took.SavePath != "/downloads/abcd" {
t.Fatalf("took %+v", took)
}
if len(client.added) != 1 || !strings.Contains(strings.ToLower(client.added[0].Magnet), "abcd") || client.added[0].Tags[0] != Tag {
t.Errorf("added %+v", client.added)
}
}
func TestGrab_RefusesWhatTheVerdictWouldNotTake(t *testing.T) {
client := &fakeClient{}
s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}}
review := Candidate{Verdict: Review, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
if took := s.Grab(context.Background(), ram, &review, nil, false, Origin{By: OriginSchedule}); !took.Refused {
t.Error("a review was taken unforced")
}
if took := s.Grab(context.Background(), ram, &review, nil, true, Origin{By: OriginOperator}); !took.Added {
t.Errorf("a forced review was not taken: %+v", took)
}
blocked := Candidate{Verdict: Blocked, InfoHash: "bbbb"}
if took := s.Grab(context.Background(), ram, &blocked, nil, true, Origin{By: OriginOperator}); !took.Refused {
t.Error("forcing took a release set aside")
}
if took := (&Service{}).Grab(context.Background(), ram, &review, nil, true, Origin{}); !took.Unconfigured || took.Missing != MissingClient {
t.Errorf("no client: %+v", took)
}
}
func TestGrab_AClientThatAlreadyHasItCounts(t *testing.T) {
client := &fakeClient{addErr: downloader.ErrDuplicate}
s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}}
c := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
if took := s.Grab(context.Background(), ram, &c, nil, false, Origin{By: OriginSchedule}); !took.Added {
t.Errorf("a duplicate: %+v", took)
}
}
// A duplicate the client does not list for Minstrel sits under another
// category, where it would read as gone half an hour later (#6176). It is set
// aside rather than taken; forced, it is refused with the client's reason.
func TestGrab_ADuplicateHeldOutsideMinstrelIsNotTaken(t *testing.T) {
client := &fakeClient{addErr: fmt.Errorf("%w: already downloading Ohio Players (lidarr)", downloader.ErrDuplicate), elsewhere: true}
s := &Service{Grabber: &Downloader{Client: client, Landing: "/downloads"}}
c := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
took := s.Grab(context.Background(), ram, &c, nil, false, Origin{By: OriginSchedule})
if took.Added || !took.Refused {
t.Fatalf("took %+v", took)
}
if c.Verdict != Blocked || !strings.Contains(c.Reason, "(already downloading Ohio Players (lidarr))") {
t.Errorf("candidate %s: %q", c.Verdict, c.Reason)
}
forced := Candidate{Verdict: Grab, InfoHash: "aaaa", Source: indexer.Result{Magnet: "magnet:?xt=urn:btih:aaaa"}}
took = s.Grab(context.Background(), ram, &forced, nil, true, Origin{By: OriginOperator})
if took.Added || !strings.Contains(took.Error, "outside Minstrel's category") {
t.Errorf("forced: %+v", took)
}
}
func TestBetter_QualityThenSeedersThenYear(t *testing.T) {
flac := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 2}
mp3 := Candidate{Verdict: Grab, Tier: release.TierLossyHigh, Seeders: 90}
if !better(flac, mp3) || better(mp3, flac) {
t.Error("quality first among grabs")
}
busy := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 20}
if !better(busy, flac) {
t.Error("then seeders")
}
dated := Candidate{Verdict: Grab, Tier: release.TierLossless, Seeders: 2, YearAgrees: true}
if !better(dated, flac) {
t.Error("then the year")
}
if better(Candidate{Verdict: Review}, flac) || !better(flac, Candidate{Verdict: Review}) {
t.Error("a grab ranks above a review")
}
}
func TestLandingFor_OneFolderPerTorrent(t *testing.T) {
if got := landingFor("/downloads", "abcd", "x"); got != "/downloads/abcd" {
t.Error(got)
}
if got := landingFor("/downloads", "", "Daft Punk: R.A.M. [FLAC]"); got != "/downloads/daft-punk-r-a-m--flac" {
t.Error(got)
}
}
func TestDecide_AnUpgradeTakesOnlyABetterCopy(t *testing.T) {
up := ram
up.Floor, up.Held = release.TierLossyLow, "MP3, about 192 kbps"
same := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [MP3 192]"), InfoHash: "aaaa", Seeders: 3}
decide(&same, up, anyQuality, nil)
if same.Verdict != Refused || same.Reasons[0].Code != CodeNotBetter ||
same.Reason != "No better than the copy you have (MP3, about 192 kbps)." {
t.Errorf("same quality: %s %q", same.Verdict, same.Reason)
}
flac := Candidate{Release: release.Parse("Daft Punk - Random Access Memories [FLAC]"), InfoHash: "bbbb", Seeders: 3}
decide(&flac, up, anyQuality, nil)
if flac.Verdict != Grab {
t.Errorf("a FLAC over a 192 MP3: %s %q", flac.Verdict, flac.Reason)
}
// A video rip has no floor: any clean copy is better.
rip := ram
decide(&same, rip, anyQuality, nil)
if same.Verdict != Grab {
t.Errorf("no floor: %s %q", same.Verdict, same.Reason)
}
}
+186
View File
@@ -0,0 +1,186 @@
// Package acquire turns the operator's configured indexers and download
// clients into working clients (M502 #5518). Ported from Fabled Librarian's
// internal/acquire.
//
// ONE BUILDER PER KIND. SourceFor makes a search source from a row and
// DownloadClient makes a download client, path mapping included. The grab,
// the importer, the stall watchdog and the settings page's Test all build
// through them, so a Test that passes vouches for the path real traffic takes.
// Clients are rebuilt from the row on every use, so a changed address or a new
// key takes effect on the next call.
package acquire
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
"git.fabledsword.com/bvandeusen/minstrel/internal/prowlarr"
)
// Kinds: which list a row appears in.
const (
KindSearch = "search"
KindDownload = "download"
)
// Jobs a row can do. An exchange row does each its switch allows; every
// other row does the one its kind names.
const (
JobSearch = "search"
JobDownload = "download"
JobEnrichment = "enrichment"
)
// Protocols: which client a row builds.
const (
ProtocolProwlarr = "prowlarr"
ProtocolTorznab = "torznab"
ProtocolQBittorrent = "qbittorrent"
ProtocolExchange = "exchange"
)
// DefaultCategory files everything Minstrel adds to a download client.
const DefaultCategory = "minstrel"
// addable is what a new row of a protocol starts as.
type addable struct {
kind string
label string
category string
timeoutMs int32
perHour int32
}
// catalogue is every protocol the operator can add. Exchange starts unpaced
// (it searches its own database); the others at 120 searches an hour, which
// keeps a Prowlarr's indexers under the rate most trackers allow.
var catalogue = map[string]addable{
ProtocolProwlarr: {kind: KindSearch, label: "Prowlarr", timeoutMs: 60000, perHour: 120},
ProtocolTorznab: {kind: KindSearch, label: "Torznab feed", timeoutMs: 30000, perHour: 120},
ProtocolQBittorrent: {kind: KindDownload, label: "qBittorrent", category: DefaultCategory, timeoutMs: 15000, perHour: 0},
ProtocolExchange: {kind: KindDownload, label: "FabledExchange", category: DefaultCategory, timeoutMs: 30000, perHour: 0},
}
// Addable says whether protocol is one the operator can add.
func Addable(protocol string) bool {
_, ok := catalogue[protocol]
return ok
}
// Searches says whether a row is a search source.
func Searches(row dbq.AcquisitionService) bool {
if row.Protocol == ProtocolExchange {
return row.ServesSearch
}
return row.Kind == KindSearch
}
// Fetches says whether a row is a download client.
func Fetches(row dbq.AcquisitionService) bool {
if row.Protocol == ProtocolExchange {
return row.ServesDownloads
}
return row.Kind == KindDownload
}
// Searcher is a search source.
type Searcher interface {
Search(ctx context.Context, q indexer.Query) ([]indexer.Result, error)
}
// Fetcher fetches a result's .torrent through the source that offered it.
type Fetcher interface {
Download(ctx context.Context, r indexer.Result) ([]byte, error)
}
// Source is one configured search source.
type Source struct {
Code string
Label string
// PerHour is the most searches the schedule sends it an hour; 0 = unpaced.
PerHour int
Search Searcher
// Fetch is nil for a source whose results are all magnets.
Fetch Fetcher
}
// SourceFor builds the search source a row describes.
func SourceFor(row dbq.AcquisitionService) Source {
src := Source{Code: row.Code, Label: row.Label, PerHour: int(row.SearchesPerHour)}
timeout := msDuration(row.TimeoutMs)
switch row.Protocol {
case ProtocolProwlarr:
c := &prowlarr.Client{Config: prowlarr.Config{
Code: row.Code, BaseURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: timeout,
}}
src.Search, src.Fetch = c, c
case ProtocolExchange:
src.Search = ExchangeFor(row)
default:
c := &indexer.Torznab{Config: indexer.TorznabConfig{
Code: row.Code, FeedURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: timeout,
}}
src.Search, src.Fetch = c, c
}
return src
}
// ExchangeFor builds the search and enrichment client for an exchange row.
func ExchangeFor(row dbq.AcquisitionService) *exchange.Client {
return &exchange.Client{
Code: row.Code,
Config: exchange.Config{
BaseURL: row.BaseUrl, APIKey: row.ApiKey, Timeout: msDuration(row.TimeoutMs),
},
}
}
// DownloadClient builds the download client a row describes, wrapped in its
// path mapping.
func DownloadClient(row dbq.AcquisitionService) (downloader.Driver, error) {
d, err := downloader.For(row.Protocol, downloader.Config{
BaseURL: row.BaseUrl, Username: row.Username, Password: row.Password,
APIKey: row.ApiKey, Category: row.Category, Timeout: msDuration(row.TimeoutMs),
})
if err != nil {
return nil, err
}
return downloader.Mapped(d, downloader.PathMap{Local: row.LocalPath, Remote: row.RemotePath}), nil
}
// DownloadClientFor builds the download client a grab went to, by the code
// of its acquisition_services row.
func DownloadClientFor(ctx context.Context, pool *pgxpool.Pool, code string) (downloader.Driver, error) {
row, err := dbq.New(pool).GetAcquisitionService(ctx, code)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("the download client %q is no longer set up", code)
}
return nil, err
}
return DownloadClient(row)
}
// Sources builds every enabled search source.
func Sources(ctx context.Context, pool *pgxpool.Pool) ([]Source, error) {
rows, err := dbq.New(pool).ListUsableAcquisitionServices(ctx, JobSearch)
if err != nil {
return nil, err
}
out := make([]Source, len(rows))
for i, r := range rows {
out[i] = SourceFor(r)
}
return out, nil
}
func msDuration(ms int32) time.Duration { return time.Duration(ms) * time.Millisecond }
+191
View File
@@ -0,0 +1,191 @@
package acquire
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// ErrNotFound is a code with no row.
var ErrNotFound = errors.New("no such service")
// ErrTooMany is a twenty-first row of one protocol.
var ErrTooMany = errors.New("there are already twenty of these")
// InvalidError is a setting the operator has to change, in words for the
// settings card.
type InvalidError struct {
Field string
Message string
}
func (e *InvalidError) Error() string { return e.Message }
func invalid(field, msg string) error { return &InvalidError{Field: field, Message: msg} }
// maxPerProtocol bounds the codes tried for a new row. Nobody runs twenty
// Prowlarrs; the bound keeps a loop over a full table finite.
const maxPerProtocol = 20
// Store reads and writes acquisition_services.
type Store struct {
pool *pgxpool.Pool
}
// NewStore returns a Store over pool.
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// List returns every row without its secrets.
func (s *Store) List(ctx context.Context) ([]dbq.ListAcquisitionServicesRow, error) {
return dbq.New(s.pool).ListAcquisitionServices(ctx)
}
// Get returns one row, secrets included: for building a client only.
func (s *Store) Get(ctx context.Context, code string) (dbq.AcquisitionService, error) {
row, err := dbq.New(s.pool).GetAcquisitionService(ctx, code)
if errors.Is(err, pgx.ErrNoRows) {
return row, ErrNotFound
}
return row, err
}
// Create adds a row of protocol, switched off and with no address. Its code
// is the protocol for the first, then protocol-2, protocol-3.
func (s *Store) Create(ctx context.Context, protocol string) (dbq.AcquisitionService, error) {
a, ok := catalogue[protocol]
if !ok {
return dbq.AcquisitionService{}, invalid("protocol", "One of prowlarr, torznab, qbittorrent, exchange.")
}
q := dbq.New(s.pool)
for n := 1; n <= maxPerProtocol; n++ {
code, label := protocol, a.label
if n > 1 {
code, label = fmt.Sprintf("%s-%d", protocol, n), fmt.Sprintf("%s %d", a.label, n)
}
row, err := q.CreateAcquisitionService(ctx, dbq.CreateAcquisitionServiceParams{
Code: code, Kind: a.kind, Protocol: protocol, Label: label,
Category: a.category, TimeoutMs: a.timeoutMs, SearchesPerHour: a.perHour,
})
if errors.Is(err, pgx.ErrNoRows) {
continue
}
return row, err
}
return dbq.AcquisitionService{}, ErrTooMany
}
// Update is everything on the settings form except the credentials.
type Update struct {
Label string
Enabled bool
BaseURL string
Username string
Category string
TimeoutMs int32
ServesSearch bool
ServesDownloads bool
ServesEnrichment bool
LocalPath string
RemotePath string
SearchesPerHour int32
}
// Save validates u and writes it to code's row.
func (s *Store) Save(ctx context.Context, code string, u Update) (dbq.AcquisitionService, error) {
u.Label = strings.TrimSpace(u.Label)
u.BaseURL = strings.TrimSpace(u.BaseURL)
u.Username = strings.TrimSpace(u.Username)
u.Category = strings.TrimSpace(u.Category)
u.LocalPath = strings.TrimSpace(u.LocalPath)
u.RemotePath = strings.TrimSpace(u.RemotePath)
if err := u.validate(); err != nil {
return dbq.AcquisitionService{}, err
}
row, err := dbq.New(s.pool).UpdateAcquisitionService(ctx, dbq.UpdateAcquisitionServiceParams{
Code: code, Label: u.Label, Enabled: u.Enabled, BaseUrl: u.BaseURL,
Username: u.Username, Category: u.Category, TimeoutMs: u.TimeoutMs,
ServesSearch: u.ServesSearch, ServesDownloads: u.ServesDownloads,
ServesEnrichment: u.ServesEnrichment, LocalPath: u.LocalPath,
RemotePath: u.RemotePath, SearchesPerHour: u.SearchesPerHour,
})
if errors.Is(err, pgx.ErrNoRows) {
return row, ErrNotFound
}
return row, err
}
// validate mirrors the table's CHECKs, so a bad value is named in the card's
// own terms before Postgres refuses it.
func (u Update) validate() error {
if u.Label == "" {
return invalid("label", "Name: give it a name.")
}
if u.Enabled && u.BaseURL == "" {
return invalid("base_url", "Address: give the address before switching this on.")
}
if u.BaseURL != "" {
parsed, err := url.Parse(u.BaseURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return invalid("base_url", "Address: start with http:// or https://.")
}
}
if u.TimeoutMs < 1000 || u.TimeoutMs > 300000 {
return invalid("timeout_ms", "Give up after: between 1000 and 300000 milliseconds.")
}
if !strings.HasPrefix(u.LocalPath, "/") {
return invalid("local_path", "Downloads folder as Minstrel sees it: a full path, starting with /.")
}
if u.RemotePath != "" && !strings.HasPrefix(u.RemotePath, "/") {
return invalid("remote_path", "Downloads folder as the client sees it: a full path starting with /, or empty when it is the same path.")
}
if u.SearchesPerHour < 0 || u.SearchesPerHour > 3600 {
return invalid("searches_per_hour", "Searches per hour: between 0 and 3600. 0 means no limit.")
}
return nil
}
// Delete removes code's row.
func (s *Store) Delete(ctx context.Context, code string) error {
n, err := dbq.New(s.pool).DeleteAcquisitionService(ctx, code)
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// SetAPIKey stores a key, trimmed; empty clears it.
func (s *Store) SetAPIKey(ctx context.Context, code, key string) error {
n, err := dbq.New(s.pool).SetAcquisitionServiceAPIKey(ctx, dbq.SetAcquisitionServiceAPIKeyParams{
Code: code, ApiKey: strings.TrimSpace(key),
})
return affected(n, err)
}
// SetPassword stores a password as typed (spaces are part of a password);
// empty clears it.
func (s *Store) SetPassword(ctx context.Context, code, password string) error {
n, err := dbq.New(s.pool).SetAcquisitionServicePassword(ctx, dbq.SetAcquisitionServicePasswordParams{
Code: code, Password: password,
})
return affected(n, err)
}
func affected(n int64, err error) error {
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
package acquire
import (
"context"
"errors"
"io"
"log/slog"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func TestStore_AddsConfiguresAndRemoves_Integration(t *testing.T) {
ctx := context.Background()
s := NewStore(newPool(t))
first, err := s.Create(ctx, ProtocolExchange)
if err != nil {
t.Fatalf("create: %v", err)
}
second, err := s.Create(ctx, ProtocolExchange)
if err != nil {
t.Fatalf("create a second: %v", err)
}
if first.Code != "exchange" || second.Code != "exchange-2" || second.Label != "FabledExchange 2" {
t.Errorf("codes = %q, %q (%q); want exchange, exchange-2", first.Code, second.Code, second.Label)
}
// A new exchange is off, does every job, files under minstrel and is
// unpaced.
if first.Enabled || first.Kind != KindDownload || !first.ServesSearch || !first.ServesDownloads ||
!first.ServesEnrichment || first.Category != DefaultCategory || first.SearchesPerHour != 0 || first.LocalPath != "/downloads" {
t.Errorf("a new exchange = %+v", first)
}
if _, err := s.Create(ctx, "transmission"); !errors.As(err, new(*InvalidError)) {
t.Errorf("an unknown protocol: err = %v", err)
}
u := Update{
Label: "Exchange", Enabled: true, BaseURL: "http://exchange:8080", Category: "minstrel",
TimeoutMs: 20000, ServesSearch: true, LocalPath: "/downloads", RemotePath: "/data/torrents",
}
saved, err := s.Save(ctx, first.Code, u)
if err != nil {
t.Fatalf("save: %v", err)
}
if !saved.Enabled || saved.ServesDownloads || saved.RemotePath != "/data/torrents" {
t.Errorf("saved = %+v", saved)
}
if _, err := s.Save(ctx, "nope", u); !errors.Is(err, ErrNotFound) {
t.Errorf("saving a missing row: err = %v", err)
}
// Secrets are write-only: the list says whether one is set, never what.
if err := s.SetAPIKey(ctx, first.Code, " key "); err != nil {
t.Fatalf("set key: %v", err)
}
full, err := s.Get(ctx, first.Code)
if err != nil || full.ApiKey != "key" {
t.Errorf("stored key = %q (err %v), want it trimmed", full.ApiKey, err)
}
rows, err := s.List(ctx)
if err != nil || len(rows) != 2 {
t.Fatalf("list: %d rows, err %v", len(rows), err)
}
for _, r := range rows {
if r.Code == first.Code && !r.HasApiKey {
t.Error("the list should say a key is set")
}
}
// Only the exchange with search switched on is a search source.
srcs, err := Sources(ctx, s.pool)
if err != nil || len(srcs) != 1 || srcs[0].Code != first.Code {
t.Errorf("sources = %+v, err %v; want only the enabled exchange", srcs, err)
}
if err := s.Delete(ctx, second.Code); err != nil {
t.Fatalf("delete: %v", err)
}
if err := s.Delete(ctx, second.Code); !errors.Is(err, ErrNotFound) {
t.Errorf("deleting twice: err = %v", err)
}
}
+134
View File
@@ -0,0 +1,134 @@
package acquire
import (
"context"
"errors"
"fmt"
"os"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/exchange"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
// TestResult is what the settings card says after Test.
type TestResult struct {
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// testQuery is a search every music indexer should answer.
const testQuery = "Radiohead"
// Test reaches the service a row describes, through the same builders real
// traffic uses. It runs whether or not the row is switched on: paste the
// address, test it, then switch it on.
func Test(ctx context.Context, row dbq.AcquisitionService) TestResult {
if strings.TrimSpace(row.BaseUrl) == "" {
return TestResult{Detail: "No address is set, so there is nothing to reach yet."}
}
if row.Protocol == ProtocolExchange {
return testExchange(ctx, row)
}
if row.Kind == KindSearch {
return testSearch(ctx, row)
}
return testDownload(ctx, row)
}
// testExchange runs each job the row has switched on and reports them all.
func testExchange(ctx context.Context, row dbq.AcquisitionService) TestResult {
var parts []string
ok := true
run := func(name string, on bool, test func() TestResult) {
if !on {
return
}
r := test()
ok = ok && r.OK
parts = append(parts, name+": "+r.Detail)
}
run("Downloads", row.ServesDownloads, func() TestResult { return testDownload(ctx, row) })
run("Search", row.ServesSearch, func() TestResult { return testSearch(ctx, row) })
run("Release details", row.ServesEnrichment, func() TestResult { return testEnrichment(ctx, row) })
if len(parts) == 0 {
return TestResult{Detail: "Every job of this FabledExchange is switched off, so there is nothing to test."}
}
return TestResult{OK: ok, Detail: strings.Join(parts, " ")}
}
// testSearch runs a real search.
func testSearch(ctx context.Context, row dbq.AcquisitionService) TestResult {
src := SourceFor(row)
results, err := src.Search.Search(ctx, indexer.Query{Text: testQuery, Categories: indexer.MusicCategories})
switch {
case errors.Is(err, indexer.ErrUnconfigured):
return TestResult{Detail: "Add this service's address, and the API key it shows on its own settings page."}
case errors.Is(err, indexer.ErrAuth):
return TestResult{Detail: indexer.FirstLine(err.Error()) + ". Check it on the service's own settings page."}
case err != nil:
return TestResult{Detail: indexer.FirstLine(err.Error())}
case len(results) == 0:
return TestResult{OK: true, Detail: "Connected, but a test search for music found nothing. Check that it has an indexer with music categories."}
}
return TestResult{OK: true, Detail: fmt.Sprintf("Connected. %d results for a test search.", len(results))}
}
// testDownload lists the client's torrents, which proves the session and not
// only the login, then checks the downloads folder from Minstrel's side.
func testDownload(ctx context.Context, row dbq.AcquisitionService) TestResult {
c, err := DownloadClient(row)
if err != nil {
return TestResult{Detail: err.Error()}
}
ts, err := c.Torrents(ctx)
if err != nil {
return TestResult{Detail: indexer.FirstLine(err.Error())}
}
detail := fmt.Sprintf("Connected. %d torrents.", len(ts))
if row.Category != "" {
detail = fmt.Sprintf("Connected. %d torrents in the %q category.", len(ts), row.Category)
}
if up, err := c.Connected(ctx); err == nil && !up {
detail += " The client reports it is not connected to any peers, so downloads will stall."
}
if problem := landingProblem(row.LocalPath); problem != "" {
return TestResult{Detail: detail + " " + problem}
}
return TestResult{OK: true, Detail: detail}
}
// landingProblem checks Minstrel can see and write the downloads folder: it
// moves finished downloads out of it. Only the local path can be checked;
// the client's own name for it cannot be seen from here.
func landingProblem(dir string) string {
info, err := os.Stat(dir)
switch {
case err != nil:
return fmt.Sprintf("But Minstrel cannot see its downloads folder at %s. Mount the download client's downloads folder there, or change where Minstrel looks for it.", dir)
case !info.IsDir():
return fmt.Sprintf("But %s is a file, not the downloads folder.", dir)
}
probe, err := os.CreateTemp(dir, ".minstrel-probe-*")
if err != nil {
return fmt.Sprintf("But Minstrel cannot write to %s, so it could not move a finished download out of it. Check the mount is not read-only and Minstrel's user can write there.", dir)
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
return ""
}
// testEnrichment asks about a hash no torrent has. A JSON "not crawled" is a
// healthy answer; a 404 from something that is not Exchange is not.
func testEnrichment(ctx context.Context, row dbq.AcquisitionService) TestResult {
_, err := ExchangeFor(row).Torrent(ctx, strings.Repeat("0", 40))
switch {
case err == nil, errors.Is(err, exchange.ErrUnknown):
return TestResult{OK: true, Detail: "Connected. The index answered."}
case errors.Is(err, exchange.ErrUnconfigured):
return TestResult{Detail: "No address is set, so there is nothing to reach yet."}
}
return TestResult{Detail: indexer.FirstLine(err.Error())}
}
+209
View File
@@ -0,0 +1,209 @@
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Indexers and download clients, admin side (M502 #5518). Rows the operator
// adds and configures; nothing is seeded. Secrets are write-only: no response
// carries one, only whether it is set.
// acquisitionBodyLimit caps a settings body. The largest is a full form of
// short strings.
const acquisitionBodyLimit = 64 << 10
type acquisitionServiceResp struct {
Code string `json:"code"`
Kind string `json:"kind"`
Protocol string `json:"protocol"`
Label string `json:"label"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
HasAPIKey bool `json:"has_api_key"`
HasPassword bool `json:"has_password"`
Username string `json:"username"`
Category string `json:"category"`
TimeoutMs int32 `json:"timeout_ms"`
ServesSearch bool `json:"serves_search"`
ServesDownloads bool `json:"serves_downloads"`
ServesEnrichment bool `json:"serves_enrichment"`
LocalPath string `json:"local_path"`
RemotePath string `json:"remote_path"`
SearchesPerHour int32 `json:"searches_per_hour"`
// Usable is switched on and addressed: the state the jobs read.
Usable bool `json:"usable"`
}
func acquisitionRespOf(r dbq.AcquisitionService) acquisitionServiceResp {
return acquisitionServiceResp{
Code: r.Code, Kind: r.Kind, Protocol: r.Protocol, Label: r.Label,
Enabled: r.Enabled, BaseURL: r.BaseUrl,
HasAPIKey: r.ApiKey != "", HasPassword: r.Password != "",
Username: r.Username, Category: r.Category, TimeoutMs: r.TimeoutMs,
ServesSearch: r.ServesSearch, ServesDownloads: r.ServesDownloads,
ServesEnrichment: r.ServesEnrichment,
LocalPath: r.LocalPath, RemotePath: r.RemotePath,
SearchesPerHour: r.SearchesPerHour,
Usable: r.Enabled && r.BaseUrl != "",
}
}
// handleListAcquisitionServices implements GET /api/admin/acquisition/services.
func (h *handlers) handleListAcquisitionServices(w http.ResponseWriter, r *http.Request) {
rows, err := h.acquisition.List(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin: list acquisition services", apierror.Internal(err))
return
}
out := make([]acquisitionServiceResp, len(rows))
for i, row := range rows {
out[i] = acquisitionRespOf(dbq.AcquisitionService{
Code: row.Code, Kind: row.Kind, Protocol: row.Protocol, Label: row.Label,
Enabled: row.Enabled, BaseUrl: row.BaseUrl, Username: row.Username,
Category: row.Category, TimeoutMs: row.TimeoutMs,
ServesSearch: row.ServesSearch, ServesDownloads: row.ServesDownloads,
ServesEnrichment: row.ServesEnrichment, LocalPath: row.LocalPath,
RemotePath: row.RemotePath, SearchesPerHour: row.SearchesPerHour,
})
out[i].HasAPIKey, out[i].HasPassword = row.HasApiKey, row.HasPassword
}
writeJSON(w, http.StatusOK, map[string]any{"services": out})
}
// handleCreateAcquisitionService implements POST /api/admin/acquisition/services
// with {"protocol": "prowlarr"|"torznab"|"qbittorrent"|"exchange"}. The row
// starts switched off, with no address.
func (h *handlers) handleCreateAcquisitionService(w http.ResponseWriter, r *http.Request) {
var body struct {
Protocol string `json:"protocol"`
}
if !decodeAcquisitionBody(w, r, &body) {
return
}
row, err := h.acquisition.Create(r.Context(), body.Protocol)
if err != nil {
h.writeAcquisitionErr(w, "admin: create acquisition service", err)
return
}
writeJSON(w, http.StatusCreated, acquisitionRespOf(row))
}
type updateAcquisitionBody struct {
Label string `json:"label"`
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
Username string `json:"username"`
Category string `json:"category"`
TimeoutMs int32 `json:"timeout_ms"`
ServesSearch bool `json:"serves_search"`
ServesDownloads bool `json:"serves_downloads"`
ServesEnrichment bool `json:"serves_enrichment"`
LocalPath string `json:"local_path"`
RemotePath string `json:"remote_path"`
SearchesPerHour int32 `json:"searches_per_hour"`
}
// handleUpdateAcquisitionService implements PUT
// /api/admin/acquisition/services/{code}: the whole form except credentials.
func (h *handlers) handleUpdateAcquisitionService(w http.ResponseWriter, r *http.Request) {
var body updateAcquisitionBody
if !decodeAcquisitionBody(w, r, &body) {
return
}
row, err := h.acquisition.Save(r.Context(), chi.URLParam(r, "code"), acquire.Update(body))
if err != nil {
h.writeAcquisitionErr(w, "admin: update acquisition service", err)
return
}
writeJSON(w, http.StatusOK, acquisitionRespOf(row))
}
// handleDeleteAcquisitionService implements DELETE
// /api/admin/acquisition/services/{code}.
func (h *handlers) handleDeleteAcquisitionService(w http.ResponseWriter, r *http.Request) {
if err := h.acquisition.Delete(r.Context(), chi.URLParam(r, "code")); err != nil {
h.writeAcquisitionErr(w, "admin: delete acquisition service", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleTestAcquisitionService implements POST
// /api/admin/acquisition/services/{code}/test. POST because it reaches
// another machine, which a prefetch must not trigger. Always 200 with
// {ok, detail}: a failed connection is the answer, not an error.
func (h *handlers) handleTestAcquisitionService(w http.ResponseWriter, r *http.Request) {
row, err := h.acquisition.Get(r.Context(), chi.URLParam(r, "code"))
if err != nil {
h.writeAcquisitionErr(w, "admin: test acquisition service", err)
return
}
writeJSON(w, http.StatusOK, acquire.Test(r.Context(), row))
}
// handleSetAcquisitionSecret implements PUT and DELETE on
// /api/admin/acquisition/services/{code}/api-key and .../password. PUT takes
// {"value": "..."}; DELETE clears it. 204 with no body either way: echoing
// anything here is how a secret ends up in a response.
func (h *handlers) handleSetAcquisitionSecret(field string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
value := ""
if r.Method == http.MethodPut {
var body struct {
Value string `json:"value"`
}
if !decodeAcquisitionBody(w, r, &body) {
return
}
if body.Value == "" {
writeErr(w, apierror.BadRequest("invalid_setting", "Paste a value, or use Remove to clear the one that is set."))
return
}
value = body.Value
}
code := chi.URLParam(r, "code")
var err error
if field == "password" {
err = h.acquisition.SetPassword(r.Context(), code, value)
} else {
err = h.acquisition.SetAPIKey(r.Context(), code, value)
}
if err != nil {
h.writeAcquisitionErr(w, "admin: set acquisition "+field, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func decodeAcquisitionBody(w http.ResponseWriter, r *http.Request, into any) bool {
err := json.NewDecoder(http.MaxBytesReader(w, r.Body, acquisitionBodyLimit)).Decode(into)
if err != nil && !errors.Is(err, io.EOF) {
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
return false
}
return true
}
func (h *handlers) writeAcquisitionErr(w http.ResponseWriter, msg string, err error) {
var invalid *acquire.InvalidError
switch {
case errors.As(err, &invalid):
writeErr(w, apierror.BadRequest("invalid_setting", invalid.Message))
case errors.Is(err, acquire.ErrNotFound):
writeErr(w, apierror.NotFound("service"))
case errors.Is(err, acquire.ErrTooMany):
writeErr(w, apierror.Conflict("too_many_services", "There are already twenty of these. Remove one you no longer use first."))
default:
writeErrWithLog(w, h.logger, msg, apierror.Internal(err))
}
}
+99 -27
View File
@@ -14,7 +14,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
@@ -23,6 +22,7 @@ import (
type duplicateMemberView struct {
TrackID string `json:"track_id"`
Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
@@ -33,12 +33,11 @@ type duplicateMemberView struct {
AddedAt string `json:"added_at"`
LikeCount int64 `json:"like_count"`
PlayCount int64 `json:"play_count"`
// LidarrState is Lidarr's view of the file as of the resolver's last pass
// (M498): "tracked" (mapped to a track, so it cannot be removed without
// Lidarr downloading it again), "unmapped", or null when not asked.
LidarrState *string `json:"lidarr_state"`
DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"`
// StreamURL lets the review play each copy, to hear whether they are the
// same song (#6168).
StreamURL string `json:"stream_url"`
}
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
@@ -170,7 +169,9 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
}
// resolvedActions are the audit actions the "Resolved automatically" tab lists.
var resolvedActions = []string{string(audit.ActionDuplicateMerge), string(audit.ActionLidarrReleaseChange)}
var resolvedActions = []string{
string(audit.ActionDuplicateMerge), string(audit.ActionDuplicateDismiss), string(audit.ActionDuplicateWrongFile),
}
func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) {
var c duplicateViewCounts
@@ -185,9 +186,8 @@ func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts,
return c, err
}
// resolvedActivityView is one thing the resolver did on its own: a merge
// (action duplicate_merge) or a Lidarr release change (lidarr_release_change).
// Details is the audit row's metadata, which names the files or releases.
// resolvedActivityView is one merge the resolver made on its own (action
// duplicate_merge). Details is the audit row's metadata, which names the files.
type resolvedActivityView struct {
ID string `json:"id"`
Action string `json:"action"`
@@ -281,6 +281,7 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
groups[n].Members = append(groups[n].Members, duplicateMemberView{
TrackID: trackID,
Title: row.Title,
ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName,
AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle,
@@ -291,13 +292,12 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
AddedAt: formatTimestamp(row.AddedAt),
LikeCount: row.LikeCount,
PlayCount: row.PlayCount,
LidarrState: row.LidarrState,
DiscNumber: row.DiscNumber,
TrackNumber: row.TrackNumber,
StreamURL: streamURL(row.TrackID),
})
candidates[n] = append(candidates[n], library.SurvivorCandidate{
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
LidarrTracked: row.LidarrState != nil && *row.LidarrState == "tracked",
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
})
}
@@ -377,7 +377,6 @@ func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (i
// keeps the report's proposal.
type mergeDuplicateRequest struct {
SurvivorTrackID string `json:"survivor_track_id"`
Unmonitor bool `json:"unmonitor"`
}
// mergeDuplicateResponse reports what the merge removed. RemovedPaths are files
@@ -385,10 +384,9 @@ type mergeDuplicateRequest struct {
type mergeDuplicateResponse struct {
SurvivorTrackID string `json:"survivor_track_id"`
RemovedPaths []string `json:"removed_paths"`
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
}
// mergeRequestBodyLimit bounds the request body. It holds one id and a flag.
// mergeRequestBodyLimit bounds the request body. It holds one id.
const mergeRequestBodyLimit = 1 << 16
// handleMergeDuplicateGroup implements POST /api/admin/library/duplicates/{id}/merge
@@ -398,10 +396,6 @@ const mergeRequestBodyLimit = 1 << 16
// Errors:
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
// removed — nothing was changed (fileRemoveAPIError)
// - 409 copy_tracked_by_lidarr when a copy to remove is one Lidarr maps to a
// track: removing it would make Lidarr download it again (M498)
// - 503 lidarr_unavailable when Lidarr is on but did not answer, so that
// cannot be checked
// - 404 duplicate_group_not_pending when the group was already resolved
// - 400 survivor_not_in_group, invalid_id, invalid_body
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
@@ -427,7 +421,7 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
}
}
res, unmonitorFailed, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID, body.Unmonitor)
res, err := h.tracks.MergeDuplicates(r.Context(), groupID, survivorID, admin.ID)
if err != nil {
if apiErr, ok := fileRemoveAPIError(err); ok {
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
@@ -439,11 +433,6 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
case errors.Is(err, library.ErrSurvivorNotInGroup):
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
case errors.Is(err, library.ErrCopyTrackedByLidarr):
writeAdminJSONErr(w, http.StatusConflict, "copy_tracked_by_lidarr")
case errors.Is(err, tracks.ErrLidarrUnavailable):
h.logger.Warn("admin: merge duplicate group: lidarr unavailable", "group_id", uuidToString(groupID), "err", err)
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unavailable")
default:
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -458,9 +447,92 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
for _, c := range res.Removed {
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
}
if body.Unmonitor && unmonitorFailed {
failed := true
resp.LidarrUnmonitorFailed = &failed
writeJSON(w, http.StatusOK, resp)
}
// refetchDuplicateRequest is the operator's answer for one recording filling
// several of an album's slots. keep_track_id is the copy whose title the
// recording is; empty means they cannot say, and every copy goes.
type refetchDuplicateRequest struct {
KeepTrackID string `json:"keep_track_id"`
}
// refetchDuplicateResponse reports what went. Blocked names the releases set
// aside; empty when the album came from before Minstrel downloaded anything.
type refetchDuplicateResponse struct {
KeptTrackID *string `json:"kept_track_id"`
RemovedPaths []string `json:"removed_paths"`
Blocked []string `json:"blocked"`
}
// handleRefetchDuplicateGroup implements POST
// /api/admin/library/duplicates/{id}/refetch (#6184): settle a group of one
// recording in several slots of an album the way the resolver would, with the
// operator's answer. The copies in slots that are not the recording's are
// removed and marked missing, the release they came from is set aside, and the
// album is wanted again. Merge is the wrong action there: it keeps one title
// whether or not it is the audio's, and fetches nothing.
//
// Errors:
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
// removed (fileRemoveAPIError)
// - 404 duplicate_group_not_pending when the group was already resolved
// - 400 not_wrong_slot when the group is not one recording in several slots
// - 400 survivor_not_in_group, invalid_id, invalid_body
func (h *handlers) handleRefetchDuplicateGroup(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
groupID, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
var body refetchDuplicateRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, mergeRequestBodyLimit)).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body")
return
}
var keepID pgtype.UUID // invalid: every copy goes
if body.KeepTrackID != "" {
if keepID, ok = parseUUID(body.KeepTrackID); !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
}
res, err := library.SettleWrongSlot(r.Context(), h.pool, h.logger, groupID, keepID, admin.ID)
if err != nil {
if apiErr, ok := fileRemoveAPIError(err); ok {
logFileRemoveFailure(h.logger, apiErr, "group_id", uuidToString(groupID))
writeErr(w, apiErr)
return
}
switch {
case errors.Is(err, library.ErrDuplicateGroupNotPending):
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
case errors.Is(err, library.ErrNotWrongSlot):
writeAdminJSONErr(w, http.StatusBadRequest, "not_wrong_slot")
case errors.Is(err, library.ErrSurvivorNotInGroup):
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
default:
h.logger.Error("admin: refetch duplicate group", "group_id", uuidToString(groupID), "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
}
return
}
resp := refetchDuplicateResponse{RemovedPaths: make([]string, 0, len(res.Removed)), Blocked: res.Blocked}
if resp.Blocked == nil {
resp.Blocked = []string{}
}
if res.Kept != nil {
id := uuidToString(res.Kept.TrackID)
resp.KeptTrackID = &id
}
for _, c := range res.Removed {
resp.RemovedPaths = append(resp.RemovedPaths, c.FilePath)
}
writeJSON(w, http.StatusOK, resp)
}
+10 -10
View File
@@ -65,27 +65,27 @@ func TestFoldDuplicateGroups(t *testing.T) {
}
// M498: the proposal shown beside a group follows the resolver's rule. The copy
// Lidarr tracks is proposed even when the other is lossless, because removing
// the tracked one would make Lidarr download it again.
func TestFoldDuplicateGroups_ProposesTheCopyLidarrTracks(t *testing.T) {
// whose tags fit the album is proposed even when the other is larger (only a
// lossless copy outranks it).
func TestFoldDuplicateGroups_ProposesTheCopyWhoseTagsFit(t *testing.T) {
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
tracked, unmapped := "tracked", "unmapped"
class := "same_release"
three := int32(3)
rows := []dbq.ListPendingDuplicateGroupMembersRow{
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(30), Class: &class,
Title: "Song", FileFormat: "flac", FileSize: 30_000_000, AddedAt: dupTS(at), LidarrState: &unmapped},
Title: "Song", FileFormat: "m4a", FileSize: 30_000_000, AddedAt: dupTS(at)},
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(31), Class: &class,
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), LidarrState: &tracked},
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), TrackNumber: &three, HasMbid: true},
}
got := foldDuplicateGroups(rows)
if len(got) != 1 {
t.Fatalf("folded %d groups, want 1", len(got))
}
g := got[0]
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "the copy Lidarr tracks" {
t.Errorf("survivor = (%s, %q), want the tracked mp3", g.SurvivorTrackID, g.SurvivorReason)
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "tags fit the album" {
t.Errorf("survivor = (%s, %q), want the tagged mp3", g.SurvivorTrackID, g.SurvivorReason)
}
if g.Class == nil || *g.Class != "same_release" || g.Members[1].LidarrState == nil || *g.Members[1].LidarrState != "tracked" {
t.Errorf("class or lidarr state not carried: %+v", g)
if g.Class == nil || *g.Class != "same_release" {
t.Errorf("class not carried: %+v", g)
}
}
+285
View File
@@ -0,0 +1,285 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
)
// Downloads and their imports (M502 #5522): the Wanted view's Activity tab,
// a person's "import anyway", "refuse" and "retry", and the Blocked tab.
// downloadView is one grab and what its import made of it.
type downloadView struct {
ID string `json:"id"`
WantID *string `json:"want_id"`
WantTitle string `json:"want_title"`
WantArtist string `json:"want_artist"`
Name string `json:"name"`
Indexer string `json:"indexer"`
Client string `json:"client"`
Quality string `json:"quality"`
Origin string `json:"origin"`
GrabbedAt string `json:"grabbed_at"`
EndedAt *string `json:"ended_at"`
EndReason string `json:"end_reason"`
Progress float32 `json:"progress"`
ImportState string `json:"import_state"`
ImportReason string `json:"import_reason"`
ImportedAt *string `json:"imported_at"`
ImportedFiles int32 `json:"imported_files"`
Destination string `json:"destination"`
Edition string `json:"edition,omitempty"`
Evidence *importer.Evidence `json:"evidence,omitempty"`
Files []string `json:"files"`
Skipped int `json:"skipped"`
RehomedFrom string `json:"rehomed_from,omitempty"`
// EndDetail says why an ended download ended (#5523).
EndDetail string `json:"end_detail"`
// Seeding: imported and still sharing; Ratio its share ratio as last
// read; SeedReleasedAt when it was removed once it had shared enough.
Seeding bool `json:"seeding"`
Ratio float32 `json:"ratio"`
SeedReleasedAt *string `json:"seed_released_at"`
// Condition is what is wrong with it now, if anything.
Condition *conditionView `json:"condition"`
}
// conditionView is an open row of the intervention ledger: what was seen,
// since when, and how many times it was retried.
type conditionView struct {
Trigger string `json:"trigger"`
Detail string `json:"detail"`
Retries int32 `json:"retries"`
Since string `json:"since"`
}
func downloadFrom(g dbq.ListGrabsRow) downloadView {
v := downloadView{
ID: uuidToString(g.ID), Name: g.Name, Indexer: g.Indexer, Client: g.Client, Quality: g.Quality,
Origin: g.Origin, GrabbedAt: formatTimestamp(g.GrabbedAt), EndReason: g.EndReason,
Progress: g.Progress, ImportState: g.ImportState, ImportReason: g.ImportReason,
ImportedFiles: g.ImportedFiles, Destination: g.Destination, Files: []string{},
EndDetail: g.EndDetail, Seeding: g.Seeding, Ratio: g.Ratio,
}
if g.SeedReleasedAt.Valid {
at := formatTimestamp(g.SeedReleasedAt)
v.SeedReleasedAt = &at
}
if g.Condition != nil {
v.Condition = &conditionView{Trigger: *g.Condition, Detail: deref(g.ConditionDetail),
Since: formatTimestamp(g.ConditionSince)}
if g.ConditionRetries != nil {
v.Condition.Retries = *g.ConditionRetries
}
}
if g.WantID.Valid {
id := uuidToString(g.WantID)
v.WantID = &id
}
if g.WantTitle != nil {
v.WantTitle = *g.WantTitle
}
if g.WantArtist != nil {
v.WantArtist = *g.WantArtist
}
if g.EndedAt.Valid {
at := formatTimestamp(g.EndedAt)
v.EndedAt = &at
}
if g.ImportedAt.Valid {
at := formatTimestamp(g.ImportedAt)
v.ImportedAt = &at
}
var det importer.Detail
if len(g.ImportDetail) > 0 && json.Unmarshal(g.ImportDetail, &det) == nil && det.Verdict.Outcome != "" {
ev := det.Verdict.Evidence
v.Evidence, v.Edition, v.Skipped, v.RehomedFrom = &ev, det.Edition, det.Skipped, det.RehomedFrom
if det.Files != nil {
v.Files = det.Files
}
}
return v
}
type downloadsPage struct {
Items []downloadView `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
// ClientsDown are the download clients not answering now (#5523).
ClientsDown []clientDownView `json:"clients_down"`
}
type clientDownView struct {
Client string `json:"client"`
Detail string `json:"detail"`
Since string `json:"since"`
}
// handleListDownloads implements GET /api/admin/downloads?offset=: running
// downloads first, then the rest newest first.
func (h *handlers) handleListDownloads(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeErr(w, apierror.BadRequest("invalid_paging", "limit and offset must be numbers"))
return
}
rows, err := dbq.New(h.pool).ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: int32(limit), Skip: int32(offset)})
if err != nil {
writeErrWithLog(w, h.logger, "admin downloads: list", apierror.Internal(err))
return
}
down, err := dbq.New(h.pool).ListClientsDown(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin downloads: clients down", apierror.Internal(err))
return
}
out := downloadsPage{Items: make([]downloadView, 0, len(rows)), Limit: limit, Offset: offset,
ClientsDown: make([]clientDownView, 0, len(down))}
for _, g := range rows {
out.Items = append(out.Items, downloadFrom(g))
}
for _, c := range down {
out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail,
Since: formatTimestamp(c.FirstSeen)})
}
writeJSON(w, http.StatusOK, out)
}
// handleAbandonDownload implements POST /api/admin/downloads/{id}/abandon: a
// running download is given up, its release set aside, the download removed
// and the album searched again.
func (h *handlers) handleAbandonDownload(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := h.lifecycle.Abandon(r.Context(), id, admin.ID); err != nil {
switch {
case errors.Is(err, pgx.ErrNoRows), errors.Is(err, lifecycle.ErrNotAbandonable):
writeErr(w, apierror.Conflict("not_running", "that download is no longer running"))
default:
writeErrWithLog(w, h.logger, "admin downloads: abandon", apierror.Internal(err))
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleImportAnyway implements POST /api/admin/downloads/{id}/import: a
// download the check held for a person (or one that failed) is imported on
// the next pass whatever the check says.
func (h *handlers) handleImportAnyway(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := dbq.New(h.pool).ForceGrabImport(r.Context(), id); err != nil {
h.writeImportErr(w, "import anyway", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRetryImport implements POST /api/admin/downloads/{id}/retry.
func (h *handlers) handleRetryImport(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := dbq.New(h.pool).RetryGrabImport(r.Context(), id); err != nil {
h.writeImportErr(w, "retry", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRefuseDownload implements POST /api/admin/downloads/{id}/refuse: the
// release is blocked, the download removed and the album searched again.
func (h *handlers) handleRefuseDownload(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := h.importer.Refuse(r.Context(), id, admin.ID); err != nil {
h.writeImportErr(w, "refuse", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handlers) writeImportErr(w http.ResponseWriter, what string, err error) {
switch {
case errors.Is(err, pgx.ErrNoRows), errors.Is(err, importer.ErrNotDecidable):
writeErr(w, apierror.Conflict("not_decidable", "that download is not waiting on a decision"))
default:
writeErrWithLog(w, h.logger, "admin downloads: "+what, apierror.Internal(err))
}
}
type releaseBlockView struct {
InfoHash string `json:"info_hash"`
Name string `json:"name"`
Reason string `json:"reason"`
Detail string `json:"detail"`
BlockedAt string `json:"blocked_at"`
// ExpiresAt is when a release given up on may be taken again; null is
// for good (#5523).
ExpiresAt *string `json:"expires_at"`
WantTitle string `json:"want_title"`
WantArtist string `json:"want_artist"`
}
type releaseBlocksPage struct {
Items []releaseBlockView `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// handleListReleaseBlocks implements GET /api/admin/release-blocks?offset=:
// every release set aside, by a person or by a check, newest first.
func (h *handlers) handleListReleaseBlocks(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeErr(w, apierror.BadRequest("invalid_paging", "limit and offset must be numbers"))
return
}
rows, err := dbq.New(h.pool).ListReleaseBlocksPage(r.Context(), dbq.ListReleaseBlocksPageParams{
MaxRows: int32(limit), Skip: int32(offset)})
if err != nil {
writeErrWithLog(w, h.logger, "admin release blocks: list", apierror.Internal(err))
return
}
out := releaseBlocksPage{Items: make([]releaseBlockView, 0, len(rows)), Limit: limit, Offset: offset}
for _, b := range rows {
v := releaseBlockView{InfoHash: b.InfoHash, Name: b.Name, Reason: b.Reason, Detail: b.Detail,
BlockedAt: formatTimestamp(b.BlockedAt)}
if b.ExpiresAt.Valid {
at := formatTimestamp(b.ExpiresAt)
v.ExpiresAt = &at
}
if b.WantTitle != nil {
v.WantTitle = *b.WantTitle
}
if b.WantArtist != nil {
v.WantArtist = *b.WantArtist
}
out.Items = append(out.Items, v)
}
writeJSON(w, http.StatusOK, out)
}
+111
View File
@@ -0,0 +1,111 @@
package api
import (
"context"
"errors"
"net/http"
"os"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// The library's folders (#6129): where the scan, the watcher and the import
// look for music, set here rather than only by MINSTREL_LIBRARY_SCAN_PATHS.
type libraryFolderView struct {
Path string `json:"path"`
// Present is whether the server can see the folder right now; a folder
// whose mount went away reads false here before anything else notices.
Present bool `json:"present"`
Tracks int64 `json:"tracks"`
}
type libraryFoldersView struct {
Folders []libraryFolderView `json:"folders"`
// Saved is false while the folders still come from
// MINSTREL_LIBRARY_SCAN_PATHS.
Saved bool `json:"saved"`
// Moved is how many tracks a save moved to a new folder.
Moved int64 `json:"moved,omitempty"`
}
func (h *handlers) libraryFolders(ctx context.Context) (libraryFoldersView, error) {
q := dbq.New(h.pool)
row, err := q.GetLibrarySettings(ctx)
if err != nil {
return libraryFoldersView{}, err
}
v := libraryFoldersView{Folders: []libraryFolderView{}, Saved: len(row.ScanPaths) > 0}
for _, root := range h.scanner.Roots() {
n, err := q.CountTracksUnder(ctx, root)
if err != nil {
return libraryFoldersView{}, err
}
info, statErr := os.Stat(root)
v.Folders = append(v.Folders, libraryFolderView{Path: root, Present: statErr == nil && info.IsDir(), Tracks: n})
}
return v, nil
}
// handleGetLibraryFolders implements GET /api/admin/library/folders.
func (h *handlers) handleGetLibraryFolders(w http.ResponseWriter, r *http.Request) {
v, err := h.libraryFolders(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin library folders: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, v)
}
type putLibraryFoldersBody struct {
Folders []struct {
Path string `json:"path"`
// Was is the folder this one replaces, whose files are now at Path;
// "" is a folder added.
Was string `json:"was"`
} `json:"folders"`
}
// handlePutLibraryFolders implements PUT /api/admin/library/folders.
//
// The whole list. A folder whose path changed moves its tracks with it, so
// their plays, likes and playlists survive; one left out is no longer read,
// and its tracks are marked missing by the next scan. After a save a scan
// starts, to pick up anything the old folders did not hold.
func (h *handlers) handlePutLibraryFolders(w http.ResponseWriter, r *http.Request) {
var body putLibraryFoldersBody
if !decodeBody(w, r, &body) {
return
}
if h.scanner == nil {
writeErr(w, apierror.Internal(errors.New("no scanner")))
return
}
changes := make([]library.RootChange, 0, len(body.Folders))
for _, f := range body.Folders {
changes = append(changes, library.RootChange{Path: f.Path, Was: f.Was})
}
res, err := h.scanner.SetRoots(r.Context(), changes)
if err != nil {
var re *library.RootsError
if errors.As(err, &re) {
writeErr(w, apierror.BadRequest("bad_folders", re.Error()))
return
}
writeErrWithLog(w, h.logger, "admin library folders: put", apierror.Internal(err))
return
}
if _, _, err := library.TryStartScan(context.Background(), h.pool, h.scanner, h.coverart,
h.logger.With("source", "library_folders"), h.scanCfg); err != nil {
h.logger.Warn("admin library folders: scan after save", "err", err)
}
v, err := h.libraryFolders(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin library folders: get", apierror.Internal(err))
return
}
v.Moved = res.Tracks
writeJSON(w, http.StatusOK, v)
}
+65 -53
View File
@@ -7,7 +7,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
)
// missingTrackView is one track whose file the scan could not find.
@@ -30,19 +29,16 @@ type missingTrackView struct {
LastPlayedAt *string `json:"last_played_at"`
}
// reacquisitionStateView is what the sweeper has done about one album
// (milestone #290), attached to the group so the operator can tell "nothing
// has happened yet" from "asked twice, still nothing" without cross-checking
// the Requests queue.
//
// NextAttemptAt is computed rather than stored: the schedule is a function of
// the attempt count and the current settings, so persisting it would go stale
// the moment an operator edited the backoff.
// reacquisitionStateView is what the wanted list says about one album
// (milestone #290, M502 #5520): when it was wanted back, and when the library
// held it again. A missing album with no want yet is still inside the grace
// window, or MusicBrainz cannot name it. Want is that want as the wanted list
// sends it, so the Missing page can show its search and download and act on
// it (M504).
type reacquisitionStateView struct {
Attempts int `json:"attempts"`
LastAttemptAt *string `json:"last_attempt_at"`
NextAttemptAt *string `json:"next_attempt_at"`
GaveUpAt *string `json:"gave_up_at"`
WantedAt *string `json:"wanted_at"`
HeldAt *string `json:"held_at"`
Want *wantView `json:"want"`
}
// missingGroupView is a directory's worth of missing tracks.
@@ -159,15 +155,14 @@ func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView
return groups
}
// attachReacquisitionState decorates each group with what the sweeper has
// done about its album (milestone #290).
// attachReacquisitionState decorates each group with its album's want.
//
// Best-effort: this is context on a list whose primary job is showing what is
// missing, so a failure here leaves the groups bare rather than failing the
// page. One batched query for the whole page, not one per group.
//
// A group is keyed by directory while re-acquisition is keyed by album, and
// those line up in practice (an album's files live in one folder) but are not
// A group is keyed by directory while a want is keyed by album, and those
// line up in practice (an album's files live in one folder) but are not
// guaranteed to — a directory holding two albums takes the first album's
// state, which is the same album its first track belongs to.
func (h *handlers) attachReacquisitionState(
@@ -183,7 +178,13 @@ func (h *handlers) attachReacquisitionState(
// groupMissingByDirectory folded them in.
dirAlbum := make(map[string]pgtype.UUID, len(groups))
ids := make([]pgtype.UUID, 0, len(groups))
// The latest loss in each folder: a want held before it is the album
// as it was before its files went, not the library holding it again.
dirLost := make(map[string]time.Time, len(groups))
for _, row := range rows {
if row.MissingSince.Valid && row.MissingSince.Time.After(dirLost[row.Directory]) {
dirLost[row.Directory] = row.MissingSince.Time
}
if _, seen := dirAlbum[row.Directory]; seen {
continue
}
@@ -191,60 +192,71 @@ func (h *handlers) attachReacquisitionState(
ids = append(ids, row.AlbumID)
}
states, err := q.GetReacquisitionForAlbums(r.Context(), ids)
wants, err := q.GetWantsForAlbums(r.Context(), ids)
if err != nil {
h.logger.Warn("admin missing: reacquisition state lookup failed", "err", err)
h.logger.Warn("admin missing: want lookup failed", "err", err)
return
}
byAlbum := make(map[string]dbq.MissingReacquisition, len(states))
for _, s := range states {
byAlbum[uuidToString(s.AlbumID)] = s
// Newest want first, so the first seen per album is its current one.
byAlbum := make(map[string]dbq.GetWantsForAlbumsRow, len(wants))
for _, w := range wants {
if _, seen := byAlbum[uuidToString(w.AlbumID)]; !seen {
byAlbum[uuidToString(w.AlbumID)] = w
}
// The settings service is optional in contexts that only wire routing
// (tests), and the backoff projection is decoration on a list whose real
// job is elsewhere — so fall back to the shipped defaults rather than
// making this a nil-pointer waiting to happen (rule #48).
cfg := reacquisition.Defaults
if h.reacqSettings != nil {
cfg = h.reacqSettings.Get()
}
full := h.wantViewsByID(r, q, byAlbum)
for i := range groups {
albumID, ok := dirAlbum[groups[i].Directory]
if !ok {
continue
}
state, ok := byAlbum[uuidToString(albumID)]
w, ok := byAlbum[uuidToString(albumID)]
if !ok {
continue
}
groups[i].Reacquisition = buildReacquisitionState(state, cfg.Backoff(state.Attempts))
// Held before its files went (every library album is on the list,
// held, since #5567): not wanted back yet, which the sweep does once
// the grace window has passed.
if w.FulfilledAt.Valid && !w.FulfilledAt.Time.After(dirLost[groups[i].Directory]) {
continue
}
view := &reacquisitionStateView{}
wantedAt := formatTimestamp(w.WantedAt)
view.WantedAt = &wantedAt
if w.FulfilledAt.Valid {
heldAt := formatTimestamp(w.FulfilledAt)
view.HeldAt = &heldAt
}
if v, ok := full[uuidToString(w.WantID)]; ok {
view.Want = &v
}
groups[i].Reacquisition = view
}
}
// buildReacquisitionState renders one album's attempt record, projecting the
// next attempt from the last one plus the backoff the current settings imply.
func buildReacquisitionState(
state dbq.MissingReacquisition,
backoff time.Duration,
) *reacquisitionStateView {
out := &reacquisitionStateView{Attempts: int(state.Attempts)}
if state.LastAttemptAt.Valid {
s := formatTimestamp(state.LastAttemptAt)
out.LastAttemptAt = &s
// Only meaningful while more attempts remain; an album that has given
// up has no next attempt to promise.
if !state.GaveUpAt.Valid {
next := formatTimestamp(pgtype.Timestamptz{
Time: state.LastAttemptAt.Time.Add(backoff),
Valid: true,
})
out.NextAttemptAt = &next
// wantViewsByID reads each album's current want in full, keyed by want id.
// Best-effort like the state it decorates: a failure leaves the groups with
// their dates and no want.
func (h *handlers) wantViewsByID(
r *http.Request,
q *dbq.Queries,
byAlbum map[string]dbq.GetWantsForAlbumsRow,
) map[string]wantView {
ids := make([]pgtype.UUID, 0, len(byAlbum))
for _, w := range byAlbum {
ids = append(ids, w.WantID)
}
out := make(map[string]wantView, len(ids))
if len(ids) == 0 {
return out
}
if state.GaveUpAt.Valid {
s := formatTimestamp(state.GaveUpAt)
out.GaveUpAt = &s
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: "all", WantIds: ids, MaxRows: int32(len(ids))})
if err != nil {
h.logger.Warn("admin missing: want detail lookup failed", "err", err)
return out
}
for _, row := range rows {
out[uuidToString(row.ID)] = wantViewOf(row)
}
return out
}
@@ -0,0 +1,84 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
// seedMissingWant marks a track's file missing for two days and puts its
// album on the wanted list, held at heldDaysAgo (0 for an open want) and
// last searched with nothing found.
func seedMissingWant(t *testing.T, h *handlers, trackID, albumID pgtype.UUID, group string, heldDaysAgo int) string {
t.Helper()
ctx := context.Background()
if _, err := h.pool.Exec(ctx,
`UPDATE tracks SET missing_since = now() - interval '2 days' WHERE id = $1`, trackID); err != nil {
t.Fatalf("mark missing: %v", err)
}
if _, err := h.pool.Exec(ctx, `DELETE FROM wants WHERE release_group_mbid = $1`, group); err != nil {
t.Fatalf("clear want: %v", err)
}
var id pgtype.UUID
if err := h.pool.QueryRow(ctx,
`INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id,
wanted_at, fulfilled_at, searched_at, search_outcome)
VALUES ($1, 'artist-'||$1, 'Artist', 'Album '||$1, 'library', $2,
now() - interval '30 days',
CASE WHEN $3::int > 0 THEN now() - make_interval(days => $3::int) END,
now() - interval '1 hour', 'nothing')
RETURNING id`, group, albumID, heldDaysAgo).Scan(&id); err != nil {
t.Fatalf("insert want: %v", err)
}
return uuidToString(id)
}
// A folder's album that was held before its files went is not wanted back
// yet (every library album is on the list, held, since #5567); an open want
// comes with its search, so the Missing page can show it and act on it (M504).
func TestListMissingTracks_WantState(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
heldTrack, _ := seedTrackOnDisk(t, h, "missheld")
openTrack, _ := seedTrackOnDisk(t, h, "missopen")
seedMissingWant(t, h, heldTrack.ID, heldTrack.AlbumID, "rg-missing-held", 10)
openID := seedMissingWant(t, h, openTrack.ID, openTrack.AlbumID, "rg-missing-open", 0)
req := withUser(httptest.NewRequest(http.MethodGet, "/api/admin/library/missing", nil), admin)
w := httptest.NewRecorder()
h.handleListMissingTracks(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var resp adminMissingResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Groups) != 2 {
t.Fatalf("groups = %d, want 2", len(resp.Groups))
}
byTitle := map[string]missingGroupView{}
for _, g := range resp.Groups {
byTitle[g.Tracks[0].Title] = g
}
if r := byTitle["AdmQ Track missheld"].Reacquisition; r != nil {
t.Errorf("held before the loss: reacquisition = %+v, want none", r)
}
r := byTitle["AdmQ Track missopen"].Reacquisition
if r == nil || r.WantedAt == nil || r.HeldAt != nil {
t.Fatalf("open want: reacquisition = %+v, want wanted and not held", r)
}
if r.Want == nil || r.Want.ID != openID {
t.Fatalf("open want: want = %+v, want id %s", r.Want, openID)
}
if r.Want.Search == nil || r.Want.Search.Outcome != "nothing" {
t.Errorf("open want: search = %+v, want outcome nothing", r.Want.Search)
}
}
-392
View File
@@ -1,392 +0,0 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config
// and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set.
type lidarrConfigView struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
DefaultQualityProfileID int `json:"default_quality_profile_id"`
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
DefaultRootFolderPath string `json:"default_root_folder_path"`
}
// maskAPIKey converts an api_key for external response: non-empty keys become
// "***"; empty (never set) keys remain "".
func maskAPIKey(key string) string {
if key == "" {
return ""
}
return "***"
}
// configToView converts a lidarrconfig.Config to a lidarrConfigView with the
// api_key masked.
func configToView(cfg lidarrconfig.Config) lidarrConfigView {
return lidarrConfigView{
Enabled: cfg.Enabled,
BaseURL: cfg.BaseURL,
APIKey: maskAPIKey(cfg.APIKey),
DefaultQualityProfileID: cfg.DefaultQualityProfileID,
DefaultMetadataProfileID: cfg.DefaultMetadataProfileID,
DefaultRootFolderPath: cfg.DefaultRootFolderPath,
}
}
// handleGetLidarrConfig implements GET /api/admin/lidarr/config.
// Returns the current Lidarr config with api_key masked.
func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: get lidarr config", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
writeJSON(w, http.StatusOK, configToView(cfg))
}
// putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config.
type putLidarrConfigBody struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
DefaultQualityProfileID int `json:"default_quality_profile_id"`
DefaultMetadataProfileID int `json:"default_metadata_profile_id"`
DefaultRootFolderPath string `json:"default_root_folder_path"`
}
// handlePutLidarrConfig implements PUT /api/admin/lidarr/config.
// Empty api_key in the body preserves the currently saved api_key.
func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) {
var body putLidarrConfigBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
return
}
// Resolve the effective api_key: empty in body = preserve saved value.
apiKey := body.APIKey
if apiKey == "" {
saved, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: put lidarr config: load saved", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
apiKey = saved.APIKey
}
// Validate: enabled=true requires base_url AND api_key.
if body.Enabled {
if body.BaseURL == "" || apiKey == "" {
writeAdminJSONErr(w, http.StatusBadRequest, "missing_required_field")
return
}
// Validate that the URL parses.
if _, err := url.ParseRequestURI(body.BaseURL); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_url")
return
}
// Defaults must be present so future Approve calls don't dispatch
// invalid POST bodies to Lidarr (which produces 4xx with field
// errors). The frontend pre-selects first values so this almost
// never fires for non-malicious traffic, but it's the right gate.
if body.DefaultQualityProfileID == 0 ||
body.DefaultMetadataProfileID == 0 ||
body.DefaultRootFolderPath == "" {
writeAdminJSONErr(w, http.StatusBadRequest, "missing_defaults")
return
}
}
cfg := lidarrconfig.Config{
Enabled: body.Enabled,
BaseURL: body.BaseURL,
APIKey: apiKey,
DefaultQualityProfileID: body.DefaultQualityProfileID,
DefaultMetadataProfileID: body.DefaultMetadataProfileID,
DefaultRootFolderPath: body.DefaultRootFolderPath,
}
if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil {
h.logger.Error("admin: put lidarr config: save", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return
}
writeJSON(w, http.StatusOK, configToView(cfg))
}
// testLidarrBody is the optional JSON body for POST /api/admin/lidarr/test.
type testLidarrBody struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
}
// testLidarrResponse is the JSON shape returned by POST /api/admin/lidarr/test.
// All fields except Ok are optional. On a failed connection (Ok=false), only
// Ok and Error are populated. On success (Ok=true), Version is populated;
// QualityProfiles / MetadataProfiles / RootFolders carry the live Lidarr
// data and ListErrors maps any per-list fetch failures by list name. The
// integrations page consumes the lists to pre-fill its dropdowns on
// first-time setup, fixing the chicken-and-egg where the dropdowns
// previously gated on cfg.Enabled.
type testLidarrResponse struct {
Ok bool `json:"ok"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
QualityProfiles []qualityProfileView `json:"quality_profiles,omitempty"`
MetadataProfiles []metadataProfileView `json:"metadata_profiles,omitempty"`
RootFolders []rootFolderView `json:"root_folders,omitempty"`
ListErrors map[string]string `json:"list_errors,omitempty"`
}
// handleTestLidarrConnection implements POST /api/admin/lidarr/test.
// Always returns 200; the ok/error fields in the response body indicate
// connection success or failure.
func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Request) {
var body testLidarrBody
// Ignore decode errors — an empty body is valid (all fields optional).
_ = json.NewDecoder(r.Body).Decode(&body)
// Fall back to saved config for any empty field.
baseURL := body.BaseURL
apiKey := body.APIKey
if baseURL == "" || apiKey == "" {
saved, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: test lidarr: load config", "err", err)
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "internal_error"})
return
}
if baseURL == "" {
baseURL = saved.BaseURL
}
if apiKey == "" {
apiKey = saved.APIKey
}
}
client := lidarr.NewClient(baseURL, apiKey)
result, err := client.Ping(r.Context())
if err != nil {
errCode := lidarrErrCode(err)
// Warn (not Error): a failed connection test is expected when the
// admin is configuring the integration and may have a typo'd URL
// or wrong key. Without this log line the underlying *net.DNSError
// / *net.OpError / TLS error is invisible to operators — they only
// see the bucket code in the UI. base_url is safe to log; api_key
// must NEVER be logged.
h.logger.Warn("admin: test lidarr ping failed",
"err", err, "base_url", baseURL, "code", errCode)
writeJSON(w, http.StatusOK, testLidarrResponse{Ok: false, Error: errCode})
return
}
quality, metadata, folders, listErrs := fetchLidarrLists(r.Context(), client)
writeJSON(w, http.StatusOK, testLidarrResponse{
Ok: true,
Version: result.Version,
QualityProfiles: quality,
MetadataProfiles: metadata,
RootFolders: folders,
ListErrors: listErrs,
})
}
// fetchLidarrLists fans out the three list calls in parallel and returns
// the lists plus a map of any per-list errors. Empty map (returned as nil
// to omit the JSON field) if all succeeded. Used by the test handler to
// pre-populate the integrations page dropdowns on the same round-trip as
// the connection check.
func fetchLidarrLists(ctx context.Context, client *lidarr.Client) (
[]qualityProfileView, []metadataProfileView, []rootFolderView, map[string]string,
) {
var (
quality []qualityProfileView
metadata []metadataProfileView
folders []rootFolderView
errMu sync.Mutex
errs = map[string]string{}
wg sync.WaitGroup
)
recordErr := func(name string, err error) {
errMu.Lock()
errs[name] = lidarrErrCode(err)
errMu.Unlock()
}
wg.Add(3)
go func() {
defer wg.Done()
ps, err := client.ListQualityProfiles(ctx)
if err != nil {
recordErr("quality_profiles", err)
return
}
quality = make([]qualityProfileView, len(ps))
for i, p := range ps {
quality[i] = qualityProfileView{ID: p.ID, Name: p.Name}
}
}()
go func() {
defer wg.Done()
ps, err := client.ListMetadataProfiles(ctx)
if err != nil {
recordErr("metadata_profiles", err)
return
}
metadata = make([]metadataProfileView, len(ps))
for i, p := range ps {
metadata[i] = metadataProfileView{ID: p.ID, Name: p.Name}
}
}()
go func() {
defer wg.Done()
fs, err := client.ListRootFolders(ctx)
if err != nil {
recordErr("root_folders", err)
return
}
folders = make([]rootFolderView, len(fs))
for i, f := range fs {
folders[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
}
}()
wg.Wait()
if len(errs) == 0 {
return quality, metadata, folders, nil
}
return quality, metadata, folders, errs
}
// lidarrErrCode maps a lidarr client error to the stable string code used in
// admin API responses.
func lidarrErrCode(err error) string {
switch {
case errors.Is(err, lidarr.ErrUnreachable):
return "lidarr_unreachable"
case errors.Is(err, lidarr.ErrAuthFailed):
return "lidarr_auth_failed"
case errors.Is(err, lidarr.ErrLookupFailed):
return "lidarr_lookup_failed"
default:
return "internal_error"
}
}
// qualityProfileView is the JSON shape for a single quality profile in the
// GET /api/admin/lidarr/quality-profiles response.
type qualityProfileView struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handleListQualityProfiles implements GET /api/admin/lidarr/quality-profiles.
func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
profiles, err := client.ListQualityProfiles(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]qualityProfileView, len(profiles))
for i, p := range profiles {
out[i] = qualityProfileView{ID: p.ID, Name: p.Name}
}
writeJSON(w, http.StatusOK, out)
}
// metadataProfileView is the JSON shape for a single metadata profile in the
// GET /api/admin/lidarr/metadata-profiles response.
type metadataProfileView struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handleListMetadataProfiles implements GET /api/admin/lidarr/metadata-profiles.
func (h *handlers) handleListMetadataProfiles(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
profiles, err := client.ListMetadataProfiles(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]metadataProfileView, len(profiles))
for i, p := range profiles {
out[i] = metadataProfileView{ID: p.ID, Name: p.Name}
}
writeJSON(w, http.StatusOK, out)
}
// rootFolderView is the JSON shape for a single root folder in the
// GET /api/admin/lidarr/root-folders response.
type rootFolderView struct {
Path string `json:"path"`
Accessible bool `json:"accessible"`
FreeSpace int64 `json:"free_space"`
}
// handleListRootFolders implements GET /api/admin/lidarr/root-folders.
func (h *handlers) handleListRootFolders(w http.ResponseWriter, r *http.Request) {
_, client, ok := h.lidarrClientFromConfig(w, r)
if !ok {
return
}
folders, err := client.ListRootFolders(r.Context())
if err != nil {
writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err))
return
}
out := make([]rootFolderView, len(folders))
for i, f := range folders {
out[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace}
}
writeJSON(w, http.StatusOK, out)
}
// lidarrClientFromConfig is a shared helper for handlers that need to proxy a
// request to Lidarr. It loads the saved config, returns 503 when disabled, and
// constructs a Client. Returns (cfg, client, true) on success; (_, nil, false)
// when a response has already been written.
func (h *handlers) lidarrClientFromConfig(w http.ResponseWriter, r *http.Request) (lidarrconfig.Config, *lidarr.Client, bool) {
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("admin: lidarr proxy: load config", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error")
return lidarrconfig.Config{}, nil, false
}
if !cfg.Enabled {
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
return lidarrconfig.Config{}, nil, false
}
return cfg, lidarr.NewClient(cfg.BaseURL, cfg.APIKey), true
}
// writeAdminJSONErr writes a flat {"error":"<code>"} JSON response. This
// mirrors the shape the RequireAdmin middleware uses and that the spec
// defines for /api/admin/* errors.
func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
}
-455
View File
@@ -1,455 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// newAdminLidarrRouter builds a test chi router with the five admin/lidarr
// endpoints. RequireAdmin middleware is applied. Tests inject a user into
// context directly (skipping RequireUser).
func newAdminLidarrRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
})
return r
}
// seedAdminUser creates a test user with is_admin=true.
func seedAdminUser(t *testing.T, h *handlers) dbq.User {
t.Helper()
return seedUser(t, h.pool, "admin", "pw", true)
}
// doAdminReq fires an HTTP request at the admin router with the given user
// injected into context (bypassing RequireUser).
func doAdminReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
var reqBody *bytes.Buffer
if body != nil {
reqBody = bytes.NewBuffer(body)
} else {
reqBody = bytes.NewBuffer(nil)
}
req := httptest.NewRequest(method, path, reqBody)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req = withUser(req, user)
w := httptest.NewRecorder()
newAdminLidarrRouter(h).ServeHTTP(w, req)
return w
}
// saveLidarrConfigFull writes a lidarr config with the given key directly
// (for admin tests that need precise api_key control).
func saveLidarrConfigFull(t *testing.T, h *handlers, cfg lidarrconfig.Config) {
t.Helper()
svc := lidarrconfig.New(h.pool)
if err := svc.Save(context.Background(), cfg); err != nil {
t.Fatalf("saveLidarrConfigFull: %v", err)
}
}
// TestHandleGetLidarrConfig_MasksAPIKey verifies that a set api_key is
// returned as "***" and never in plaintext.
func TestHandleGetLidarrConfig_MasksAPIKey(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: "http://lidarr.lan:8686",
APIKey: "secret123",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.APIKey == "secret123" {
t.Error("api_key leaked in response; want ***")
}
if resp.APIKey != "***" {
t.Errorf("api_key = %q, want ***", resp.APIKey)
}
}
// TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty verifies that when api_key is
// unset in the DB, GET returns api_key="" (not "***").
func TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Save config with empty api_key (null in DB via lidarrconfig.Service).
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: false,
BaseURL: "",
APIKey: "",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.APIKey != "" {
t.Errorf("api_key = %q, want empty string", resp.APIKey)
}
}
// TestHandlePutLidarrConfig_EmptyKeyPreservesSaved verifies that a PUT with
// api_key="" does not overwrite the saved api_key.
func TestHandlePutLidarrConfig_EmptyKeyPreservesSaved(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Seed a config with a known api_key.
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: "http://lidarr.lan:8686",
APIKey: "originalkey",
})
// PUT with empty api_key — should preserve "originalkey". enabled=true
// requires the defaults gate (missing_defaults) to be satisfied.
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"","default_quality_profile_id":1,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
// Confirm the saved api_key is still "originalkey" by reading raw config.
svc := lidarrconfig.New(h.pool)
saved, err := svc.Get(context.Background())
if err != nil {
t.Fatalf("get saved config: %v", err)
}
if saved.APIKey != "originalkey" {
t.Errorf("saved api_key = %q, want originalkey", saved.APIKey)
}
}
// TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400 verifies that enabling
// Lidarr without a base_url returns 400 with missing_required_field.
func TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
body := []byte(`{"enabled":true,"base_url":"","api_key":"somekey"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "missing_required_field" {
t.Errorf("error = %q, want missing_required_field", resp["error"])
}
}
// TestHandlePutLidarrConfig_HappyPath verifies a valid PUT returns 200 with
// the updated config and the api_key masked.
func TestHandlePutLidarrConfig_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if !resp.Enabled {
t.Error("enabled = false, want true")
}
if resp.BaseURL != "http://lidarr.lan:8686" {
t.Errorf("base_url = %q, want http://lidarr.lan:8686", resp.BaseURL)
}
if resp.APIKey != "***" {
t.Errorf("api_key = %q, want ***", resp.APIKey)
}
if resp.DefaultQualityProfileID != 2 {
t.Errorf("default_quality_profile_id = %d, want 2", resp.DefaultQualityProfileID)
}
if resp.DefaultRootFolderPath != "/music" {
t.Errorf("default_root_folder_path = %q, want /music", resp.DefaultRootFolderPath)
}
}
// TestHandleTestLidarrConnection_HappyPath verifies that POST /test against a
// stub returning valid status JSON responds 200 with ok=true and the version.
func TestHandleTestLidarrConnection_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/system/status":
_, _ = w.Write([]byte(`{"version":"2.0.5"}`))
case "/api/v1/qualityprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"},{"id":2,"name":"MP3-320"}]`))
case "/api/v1/metadataprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case "/api/v1/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(stub.Close)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
// POST with empty body — falls back to saved config.
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`), admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
if resp["version"] != "2.0.5" {
t.Errorf("version = %v, want 2.0.5", resp["version"])
}
qp, ok := resp["quality_profiles"].([]any)
if !ok || len(qp) != 2 {
t.Errorf("quality_profiles = %v, want 2 entries", resp["quality_profiles"])
}
mp, ok := resp["metadata_profiles"].([]any)
if !ok || len(mp) != 1 {
t.Errorf("metadata_profiles = %v, want 1 entry", resp["metadata_profiles"])
}
rf, ok := resp["root_folders"].([]any)
if !ok || len(rf) != 1 {
t.Errorf("root_folders = %v, want 1 entry", resp["root_folders"])
}
if _, present := resp["list_errors"]; present {
t.Errorf("list_errors should be omitted when all lists succeed; got %v", resp["list_errors"])
}
}
// TestHandleTestLidarrConnection_PartialListFailure verifies that POST /test
// against a Lidarr that responds OK to the ping + two of three lists still
// reports ok=true with the failing list omitted and named in list_errors.
func TestHandleTestLidarrConnection_PartialListFailure(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/system/status":
_, _ = w.Write([]byte(`{"version":"2.0.5"}`))
case "/api/v1/qualityprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"}]`))
case "/api/v1/metadataprofile":
http.Error(w, "boom", http.StatusInternalServerError)
case "/api/v1/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(stub.Close)
body := []byte(`{"base_url":"` + stub.URL + `","api_key":"k"}`)
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
if _, present := resp["metadata_profiles"]; present {
t.Errorf("metadata_profiles should be omitted on failure; got %v", resp["metadata_profiles"])
}
listErrs, ok := resp["list_errors"].(map[string]any)
if !ok {
t.Fatalf("list_errors missing or wrong shape: %v", resp["list_errors"])
}
if _, present := listErrs["metadata_profiles"]; !present {
t.Errorf("list_errors.metadata_profiles should be present, got %v", listErrs)
}
}
// TestHandleTestLidarrConnection_Unreachable verifies that POST /test against
// an unreachable URL returns 200 with ok=false and lidarr_unreachable.
func TestHandleTestLidarrConnection_Unreachable(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Point at a URL that will immediately refuse connections.
body := []byte(`{"base_url":"http://127.0.0.1:1","api_key":"k"}`)
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != false {
t.Errorf("ok = %v, want false", resp["ok"])
}
if resp["error"] != "lidarr_unreachable" {
t.Errorf("error = %v, want lidarr_unreachable", resp["error"])
}
}
// TestHandleListQualityProfiles_HappyPath verifies that GET /quality-profiles
// proxies and normalizes the Lidarr response.
func TestHandleListQualityProfiles_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := newLidarrStub(t, `[{"id":1,"name":"Lossless"},{"id":2,"name":"Standard"}]`)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/quality-profiles", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var profiles []qualityProfileView
if err := json.Unmarshal(w.Body.Bytes(), &profiles); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(profiles) != 2 {
t.Fatalf("len(profiles) = %d, want 2", len(profiles))
}
if profiles[0].ID != 1 || profiles[0].Name != "Lossless" {
t.Errorf("profiles[0] = %+v, want {1 Lossless}", profiles[0])
}
if profiles[1].ID != 2 || profiles[1].Name != "Standard" {
t.Errorf("profiles[1] = %+v, want {2 Standard}", profiles[1])
}
}
// TestHandleListRootFolders_HappyPath verifies that GET /root-folders proxies
// and normalizes the Lidarr response.
func TestHandleListRootFolders_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := newLidarrStub(t, `[{"path":"/music","accessible":true,"freeSpace":1234567890}]`)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/root-folders", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var folders []rootFolderView
if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(folders) != 1 {
t.Fatalf("len(folders) = %d, want 1", len(folders))
}
f := folders[0]
if f.Path != "/music" {
t.Errorf("path = %q, want /music", f.Path)
}
if !f.Accessible {
t.Error("accessible = false, want true")
}
if f.FreeSpace != 1234567890 {
t.Errorf("free_space = %d, want 1234567890", f.FreeSpace)
}
}
// TestAdminEndpoints_NonAdmin403 is a table-driven test verifying that every
// admin endpoint returns 403 with not_authorized for a non-admin user.
func TestAdminEndpoints_NonAdmin403(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
cases := []struct {
method string
path string
body []byte
}{
{http.MethodGet, "/api/admin/lidarr/config", nil},
{http.MethodPut, "/api/admin/lidarr/config", []byte(`{}`)},
{http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`)},
{http.MethodGet, "/api/admin/lidarr/quality-profiles", nil},
{http.MethodGet, "/api/admin/lidarr/root-folders", nil},
}
for _, tc := range cases {
tc := tc
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := doAdminReq(t, h, tc.method, tc.path, tc.body, nonAdmin)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "not_authorized" {
t.Errorf("error = %q, want not_authorized", resp["error"])
}
})
}
}
+11 -56
View File
@@ -10,8 +10,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
)
// adminQueueRowView is a single aggregated row in the admin queue response.
@@ -21,7 +20,6 @@ type adminQueueRowView struct {
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
AlbumID string `json:"album_id"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
ReportCount int32 `json:"report_count"`
LatestAt string `json:"latest_at"`
ReasonCounts map[string]int `json:"reason_counts"`
@@ -47,7 +45,7 @@ func formatTimestamp(ts pgtype.Timestamptz) string {
// handleListAdminQuarantine implements GET /api/admin/quarantine.
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context())
rows, err := h.quarantine.ListAdminQueue(r.Context())
if err != nil {
h.logger.Error("admin: list quarantine", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -71,7 +69,6 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
AlbumID: uuidToString(row.AlbumID),
LidarrAlbumMBID: row.LidarrAlbumMBID,
ReportCount: row.ReportCount,
LatestAt: formatTimestamp(row.LatestAt),
ReasonCounts: row.ReasonCounts,
@@ -81,12 +78,11 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, out)
}
// actionResultView is the response shape for the three admin destructive
// actions (resolve, delete-file, delete-via-lidarr).
// actionResultView is the response shape for the admin actions (resolve,
// delete-file).
type actionResultView struct {
ActionID string `json:"action_id"`
AffectedUsers int32 `json:"affected_users"`
DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
}
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
@@ -101,9 +97,9 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID)
action, err := h.quarantine.Resolve(r.Context(), id, admin.ID)
if err != nil {
if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
if errors.Is(err, quarantine.ErrTrackNotFound) {
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
return
}
@@ -131,7 +127,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID)
if err != nil {
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
// message is the part that tells the operator which directory and uid.
@@ -141,7 +137,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
return
}
switch {
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
case errors.Is(err, quarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
default:
h.logger.Error("admin: delete file", "err", err)
@@ -158,48 +154,6 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
})
}
// handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr.
func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
return
}
id, ok := parseUUID(chi.URLParam(r, "track_id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrLidarrDisabled):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing):
writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing")
case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound):
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed")
case errors.Is(err, lidarr.ErrUnreachable):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
case errors.Is(err, lidarr.ErrAuthFailed):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
default:
h.logger.Error("admin: delete via lidarr", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
}
return
}
// Broadcast: same rationale as delete-file — track row gone everywhere.
h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true)
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
DeletedTrackCount: &deleted,
})
}
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
type actionLogView struct {
ID string `json:"id"`
@@ -209,7 +163,8 @@ type actionLogView struct {
AlbumTitle *string `json:"album_title,omitempty"`
Action string `json:"action"`
AdminID *string `json:"admin_id,omitempty"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
// AlbumMBID is set on the older deleted_album rows: the album removed.
AlbumMBID *string `json:"album_mbid,omitempty"`
AffectedUsers int32 `json:"affected_users"`
CreatedAt string `json:"created_at"`
}
@@ -248,7 +203,7 @@ func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Re
AlbumTitle: row.AlbumTitle,
Action: string(row.Action),
AdminID: adminID,
LidarrAlbumMBID: row.LidarrAlbumMbid,
AlbumMBID: row.AlbumMbid,
AffectedUsers: row.AffectedUsers,
CreatedAt: formatTimestamp(row.CreatedAt),
})
+6 -194
View File
@@ -15,9 +15,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
)
// newAdminQuarantineRouter builds a test chi router with the five admin
@@ -30,7 +27,6 @@ func newAdminQuarantineRouter(h *handlers) chi.Router {
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
})
return r
@@ -56,27 +52,11 @@ func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body [
return w
}
// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service
// with a real clientFn pointing at the (current) saved lidarr_config. This
// mirrors testHandlersWithClientFn for the requests test file.
func installQuarantineClientFn(t *testing.T, h *handlers) {
t.Helper()
cfg := lidarrconfig.New(h.pool)
clientFn := func() *lidarr.Client {
c, err := cfg.Get(context.Background())
if err != nil || !c.Enabled || c.BaseURL == "" {
return nil
}
return lidarr.NewClient(c.BaseURL, c.APIKey)
}
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir)
}
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
// Service for setup. Used to construct admin queue scenarios.
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
t.Helper()
if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
if _, err := h.quarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
t.Fatalf("flagDirect: %v", err)
}
}
@@ -110,49 +90,13 @@ func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, strin
return tr, path
}
// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is
// required for delete-via-lidarr scenarios.
func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "DvL Artist "+unique)
dir := t.TempDir()
path := filepath.Join(dir, "track-"+unique+".mp3")
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
mbid := albumMBID
album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique,
ArtistID: artist.ID, Mbid: &mbid,
})
if err != nil {
t.Fatalf("UpsertAlbum: %v", err)
}
one := int32(1)
tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "DvL Track " + unique,
AlbumID: album.ID,
ArtistID: artist.ID,
TrackNumber: &one,
DurationMs: 30000,
FilePath: path,
FileSize: 5,
FileFormat: "mp3",
})
if err != nil {
t.Fatalf("UpsertTrack: %v", err)
}
return tr, path
}
// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
// track with mixed reasons. Verifies one aggregated row with report_count=3,
// reason_counts populated, and a Reports list of length 3.
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -192,8 +136,7 @@ func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
func TestResolveQuarantine_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -227,7 +170,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
if actions[0].Action != dbq.LidarrQuarantineActionKindResolved {
if actions[0].Action != dbq.QuarantineActionKindResolved {
t.Errorf("audit action = %v, want resolved", actions[0].Action)
}
}
@@ -237,8 +180,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -268,141 +210,12 @@ func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
}
}
// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns
// a single album for the lookup and 200 for the delete. Verifies the
// audit row is written and the local track row is deleted.
func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid-dvl","title":"Al","artistId":7}]`))
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got actionResultView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.AffectedUsers != 1 {
t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
}
if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 {
t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount)
}
// Track row should be gone.
if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
t.Error("track row still exists after delete-via-lidarr")
}
// Audit row written with the lidarr_album_mbid.
actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
if err != nil {
t.Fatalf("list actions: %v", err)
}
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr {
t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action)
}
if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" {
t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid)
}
}
// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is
// not configured.
func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
// Lidarr config is reset (disabled).
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "lidarr_disabled" {
t.Errorf("error = %q, want lidarr_disabled", resp["error"])
}
}
// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing
// when the parent album has no mbid.
func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
// Track whose album has NO mbid.
track, _ := seedTrackOnDisk(t, h, "no-mbid")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "album_mbid_missing" {
t.Errorf("error = %q, want album_mbid_missing", resp["error"])
}
}
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
// endpoints reject non-admin callers with 403.
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
fakeID := "00000000-0000-0000-0000-000000000001"
@@ -414,7 +227,6 @@ func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
{http.MethodGet, "/api/admin/quarantine"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"},
{http.MethodGet, "/api/admin/quarantine/actions"},
}
for _, tc := range cases {
+4 -16
View File
@@ -13,18 +13,14 @@ import (
// reacquisitionSettingsResp is the admin card's payload (milestone #290).
//
// Carries more than the stored settings: [UnnameableAlbums] is the count of
// albums with missing files that can never be auto-requested because neither
// they nor their artist has an MBID. The card states that number rather than
// leaving the operator to wonder why some rows never get a request — a
// feature that silently does nothing for part of its input reads as broken.
// albums with missing files that can never be wanted automatically because
// neither they nor their artist has an MBID. The card states that number
// rather than leaving the operator to wonder why some rows never get wanted —
// a feature that silently does nothing for part of its input reads as broken.
type reacquisitionSettingsResp struct {
Enabled bool `json:"enabled"`
GraceHours int32 `json:"grace_hours"`
BackoffBaseHours int32 `json:"backoff_base_hours"`
BackoffMaxHours int32 `json:"backoff_max_hours"`
MaxAttempts int32 `json:"max_attempts"`
MaxPerPass int32 `json:"max_per_pass"`
AutoApprove bool `json:"auto_approve"`
UnnameableAlbums int64 `json:"unnameable_albums"`
}
@@ -44,11 +40,7 @@ func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *h
_, err := h.reacqSettings.Set(r.Context(), reacquisition.Settings{
Enabled: req.Enabled,
GraceHours: req.GraceHours,
BackoffBaseHours: req.BackoffBaseHours,
BackoffMaxHours: req.BackoffMaxHours,
MaxAttempts: req.MaxAttempts,
MaxPerPass: req.MaxPerPass,
AutoApprove: req.AutoApprove,
})
if err != nil {
// The Go-side validation mirrors migration 0056's CHECKs so the
@@ -71,11 +63,7 @@ func (h *handlers) reacquisitionPayload(r *http.Request) reacquisitionSettingsRe
out := reacquisitionSettingsResp{
Enabled: cur.Enabled,
GraceHours: cur.GraceHours,
BackoffBaseHours: cur.BackoffBaseHours,
BackoffMaxHours: cur.BackoffMaxHours,
MaxAttempts: cur.MaxAttempts,
MaxPerPass: cur.MaxPerPass,
AutoApprove: cur.AutoApprove,
}
// Best-effort: the settings are the point of this endpoint, and failing
// the whole card because a count query hiccuped would be the wrong trade.
+27 -33
View File
@@ -7,12 +7,13 @@ import (
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// validRequestStatuses is the set of allowed values for the ?status= param.
@@ -50,7 +51,7 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques
limit = v
}
rows, err := h.lidarrRequests.ListByStatus(r.Context(), status, int32(limit))
rows, err := h.requests.ListByStatus(r.Context(), status, int32(limit))
if err != nil {
h.logger.Error("admin: list requests", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -68,12 +69,15 @@ func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Reques
}
// approveRequestBody is the optional JSON body for POST /api/admin/requests/:id/approve.
// profile_id picks the release-type profile an artist request is followed
// on; omitted, the default profile.
type approveRequestBody struct {
QualityProfileID int `json:"quality_profile_id"`
RootFolderPath string `json:"root_folder_path"`
ProfileID string `json:"profile_id"`
}
// handleApproveRequest implements POST /api/admin/requests/:id/approve.
// handleApproveRequest implements POST /api/admin/requests/:id/approve. The
// request goes on the wanted list (M502 #5520): an album or track request
// wants its album, an artist request follows the artist.
func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -90,35 +94,25 @@ func (h *handlers) handleApproveRequest(w http.ResponseWriter, r *http.Request)
var body approveRequestBody
// Body is entirely optional; ignore decode errors.
_ = json.NewDecoder(r.Body).Decode(&body)
var profile pgtype.UUID
if body.ProfileID != "" {
if profile, ok = parseUUID(body.ProfileID); !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_profile")
return
}
}
row, err := h.lidarrRequests.Approve(r.Context(), id, admin.ID, lidarrrequests.ApproveOverrides{
QualityProfileID: body.QualityProfileID,
RootFolderPath: body.RootFolderPath,
})
row, err := h.requests.Approve(r.Context(), id, admin.ID, profile)
if err != nil {
switch {
case errors.Is(err, lidarrrequests.ErrLidarrDisabled):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
case errors.Is(err, lidarrrequests.ErrDefaultsIncomplete):
writeAdminJSONErr(w, http.StatusBadRequest, "lidarr_defaults_incomplete")
case errors.Is(err, lidarrrequests.ErrNotFound):
case errors.Is(err, requests.ErrNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
case errors.Is(err, lidarrrequests.ErrNotPending):
case errors.Is(err, requests.ErrNotPending):
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
case errors.Is(err, lidarr.ErrUnreachable):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
case errors.Is(err, lidarr.ErrAuthFailed):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
case errors.Is(err, lidarr.ErrServerError):
// Lidarr itself 5xx'd — its response body is in err's wrapped
// message (see lidarr.readBodySnippet). Logged below for ops.
h.logger.Error("admin: approve request: lidarr 5xx", "err", err)
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_server_error")
case errors.Is(err, lidarr.ErrLookupFailed):
// Lidarr returned 4xx — usually "artist already exists" or
// "no metadata found." Log so ops can see the actual reason.
h.logger.Warn("admin: approve request: lidarr 4xx", "err", err)
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_rejected")
case errors.Is(err, wanted.ErrProfileNotFound):
writeAdminJSONErr(w, http.StatusBadRequest, "profile_not_found")
case errors.Is(err, wanted.ErrNotFollowable):
writeAdminJSONErr(w, http.StatusBadRequest, "not_followable")
default:
h.logger.Error("admin: approve request", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -153,12 +147,12 @@ func (h *handlers) handleRejectRequest(w http.ResponseWriter, r *http.Request) {
var body rejectRequestBody
_ = json.NewDecoder(r.Body).Decode(&body)
row, err := h.lidarrRequests.Reject(r.Context(), id, admin.ID, body.Notes)
row, err := h.requests.Reject(r.Context(), id, admin.ID, body.Notes)
if err != nil {
switch {
case errors.Is(err, lidarrrequests.ErrNotFound):
case errors.Is(err, requests.ErrNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
case errors.Is(err, lidarrrequests.ErrNotPending):
case errors.Is(err, requests.ErrNotPending):
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
default:
h.logger.Error("admin: reject request", "err", err)
+77 -218
View File
@@ -6,16 +6,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
)
// newAdminRequestsRouter builds a test chi router with the three admin/requests
@@ -52,24 +48,7 @@ func doAdminRequestReq(t *testing.T, h *handlers, method, path string, body []by
return w
}
// testHandlersWithClientFn creates a handlers instance with a real clientFn
// so Service.Approve actually calls Lidarr during tests.
func testHandlersWithClientFn(t *testing.T) (*handlers, *lidarrconfig.Service) {
t.Helper()
h, _ := testHandlers(t)
lidarrCfg := lidarrconfig.New(h.pool)
clientFn := func() *lidarr.Client {
cfg, err := lidarrCfg.Get(context.Background())
if err != nil || !cfg.Enabled || cfg.BaseURL == "" {
return nil
}
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
}
h.lidarrRequests = lidarrrequests.NewService(h.pool, lidarrCfg, clientFn, nil)
return h, lidarrCfg
}
// seedPendingArtistRequest creates a pending artist-kind lidarr request row
// seedPendingArtistRequest creates a pending artist-kind request row
// for the given user and returns its parsed requestView.
func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
t.Helper()
@@ -79,8 +58,8 @@ func seedPendingArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMB
// TestHandleListAdminRequests_DefaultStatus_Pending seeds 2 pending + 1 rejected
// and verifies that GET without ?status returns only the 2 pending rows.
func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -90,7 +69,7 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
// Reject one request directly via service.
rv3 := createArtistRequest(t, h, alice, "mbid-reject-1", "Reject Me")
_, err := h.lidarrRequests.Reject(context.Background(), rv3.ID, admin.ID, "test")
_, err := h.requests.Reject(context.Background(), rv3.ID, admin.ID, "test")
if err != nil {
t.Fatalf("reject seed: %v", err)
}
@@ -117,8 +96,8 @@ func TestHandleListAdminRequests_DefaultStatus_Pending(t *testing.T) {
// TestHandleListAdminRequests_StatusFilter verifies GET ?status=rejected returns
// only rejected rows.
func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -126,7 +105,7 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
createArtistRequest(t, h, alice, "mbid-pending-f1", "Pending Filter 1")
rv2 := createArtistRequest(t, h, alice, "mbid-rejected-f1", "Rejected Filter 1")
_, err := h.lidarrRequests.Reject(context.Background(), rv2.ID, admin.ID, "")
_, err := h.requests.Reject(context.Background(), rv2.ID, admin.ID, "")
if err != nil {
t.Fatalf("reject seed: %v", err)
}
@@ -151,8 +130,8 @@ func TestHandleListAdminRequests_StatusFilter(t *testing.T) {
// TestHandleListAdminRequests_BadStatus_400 verifies GET with an invalid
// ?status returns 400.
func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -169,190 +148,11 @@ func TestHandleListAdminRequests_BadStatus_400(t *testing.T) {
}
}
// TestHandleApproveRequest_HappyPath seeds a Lidarr stub, seeds a pending
// artist request, and verifies POST /approve → 200 with row status=approved.
func TestHandleApproveRequest_HappyPath(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-approve", "Approve Me")
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got requestView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.Status != "approved" {
t.Errorf("status = %q, want approved", got.Status)
}
if got.ID != rv.ID {
t.Errorf("id mismatch")
}
}
// TestHandleApproveRequest_OverrideUsed seeds config with default qp=1/root="/m",
// approves with override qp=5/root="/other", verifies the stub received those
// values and the row reflects the overrides.
func TestHandleApproveRequest_OverrideUsed(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
var capturedBody []byte
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
capturedBody = buf[:n]
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
// Save config with default qp=1, root="/m".
if err := lidarrconfig.New(h.pool).Save(context.Background(), lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
DefaultQualityProfileID: 1,
DefaultRootFolderPath: "/m",
}); err != nil {
t.Fatalf("save config: %v", err)
}
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-override", "Override Me")
body := []byte(`{"quality_profile_id":5,"root_folder_path":"/other"}`)
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
// Verify stub received the override values.
var stubPayload map[string]any
if err := json.Unmarshal(capturedBody, &stubPayload); err != nil {
t.Fatalf("decode stub payload: %v; body = %s", err, capturedBody)
}
if qp, _ := stubPayload["qualityProfileId"].(float64); int(qp) != 5 {
t.Errorf("stub qualityProfileId = %v, want 5", stubPayload["qualityProfileId"])
}
if rf, _ := stubPayload["rootFolderPath"].(string); rf != "/other" {
t.Errorf("stub rootFolderPath = %q, want /other", rf)
}
// Verify the returned row reflects the overrides.
var got requestView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v; body = %s", err, w.Body.String())
}
if got.QualityProfileID == nil || *got.QualityProfileID != 5 {
t.Errorf("quality_profile_id = %v, want 5", got.QualityProfileID)
}
if got.RootFolderPath == nil || *got.RootFolderPath != "/other" {
t.Errorf("root_folder_path = %v, want /other", got.RootFolderPath)
}
}
// TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred points config
// at an unreachable port and verifies the durable-approve contract: POST
// /approve still succeeds (200) and the request becomes approved with the
// Lidarr add deferred (lidarr_add_confirmed_at NULL) for the reconciler to
// retry — the admin's decision is never lost to a transient Lidarr outage.
func TestHandleApproveRequest_LidarrUnreachable_ApprovesDeferred(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-unreachable", "Unreachable")
w := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (durable approve despite Lidarr down); body = %s", w.Code, w.Body.String())
}
// Must no longer be pending.
pending, err := h.lidarrRequests.ListByStatus(context.Background(), "pending", 10)
if err != nil {
t.Fatalf("list pending: %v", err)
}
for _, r := range pending {
if r.ID == rv.ID {
t.Error("row still pending after durable approve")
}
}
// Must be approved with the add deferred (confirmed_at NULL) so the
// reconciler retries it — not failed, not lost.
approved, err := h.lidarrRequests.ListByStatus(context.Background(), "approved", 10)
if err != nil {
t.Fatalf("list approved: %v", err)
}
var got *dbq.LidarrRequest
for i := range approved {
if approved[i].ID == rv.ID {
got = &approved[i]
break
}
}
if got == nil {
t.Fatalf("row %s not in approved after approve with Lidarr down", uuidToString(rv.ID))
}
if got.LidarrAddConfirmedAt.Valid {
t.Error("lidarr_add_confirmed_at set despite Lidarr unreachable; want NULL (deferred to reconciler)")
}
}
// TestHandleApproveRequest_NotPending_409 pre-rejects a row and verifies
// POST /approve → 409 request_not_pending.
func TestHandleApproveRequest_NotPending_409(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
// Need an enabled Lidarr config so Approve reaches the not-pending check.
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":1}`))
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -360,7 +160,7 @@ func TestHandleApproveRequest_NotPending_409(t *testing.T) {
rv := seedPendingArtistRequest(t, h, alice, "artist-mbid-notpending", "Not Pending")
// Pre-reject the row.
_, err := h.lidarrRequests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
_, err := h.requests.Reject(context.Background(), rv.ID, admin.ID, "pre-rejected")
if err != nil {
t.Fatalf("pre-reject: %v", err)
}
@@ -381,8 +181,8 @@ func TestHandleApproveRequest_NotPending_409(t *testing.T) {
// TestHandleRejectRequest_HappyPath verifies POST /reject with notes returns
// 200 and the row transitions to rejected with notes saved.
func TestHandleRejectRequest_HappyPath(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -410,8 +210,8 @@ func TestHandleRejectRequest_HappyPath(t *testing.T) {
// TestHandleRejectRequest_NoNotes_OK verifies POST /reject with empty body
// returns 200 (notes are optional).
func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
@@ -435,8 +235,8 @@ func TestHandleRejectRequest_NoNotes_OK(t *testing.T) {
// TestAdminRequestsEndpoints_NonAdmin403 is a table-driven test verifying that
// all 3 admin request endpoints reject non-admin users with 403.
func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
h, _ := testHandlers(t)
resetRequests(t, h)
nonAdmin := seedUser(t, h.pool, "regular", "pw", false)
@@ -470,3 +270,62 @@ func TestAdminRequestsEndpoints_NonAdmin403(t *testing.T) {
})
}
}
// TestHandleApproveRequest_AlbumBecomesAWant: approving an album request puts
// its release group on the wanted list, as the requester's.
func TestHandleApproveRequest_AlbumBecomesAWant(t *testing.T) {
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
w := doCreateRequest(h, alice, `{"kind":"album","artist_mbid":"artist-x","artist_name":"Gorillaz","album_mbid":"group-x","album_title":"Demon Days"}`)
if w.Code != http.StatusCreated {
t.Fatalf("create: %d %s", w.Code, w.Body.String())
}
var rv requestView
_ = json.Unmarshal(w.Body.Bytes(), &rv)
resp := doAdminRequestReq(t, h, http.MethodPost, "/api/admin/requests/"+uuidToString(rv.ID)+"/approve", nil, admin)
if resp.Code != http.StatusOK {
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
}
want, err := dbq.New(h.pool).GetWantByReleaseGroup(context.Background(), "group-x")
if err != nil {
t.Fatalf("no want recorded: %v", err)
}
if want.Title != "Demon Days" || want.CreatedBy != alice.ID || want.Reason != "request" {
t.Errorf("want = %+v", want)
}
}
// TestHandleApproveRequest_ArtistOnTheChosenProfile: an artist request is
// followed on the profile the admin picked; an unknown profile is a 400.
func TestHandleApproveRequest_ArtistOnTheChosenProfile(t *testing.T) {
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "alice", "pw", false)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
profile, err := dbq.New(h.pool).CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
})
if err != nil {
t.Fatal(err)
}
rv := seedPendingArtistRequest(t, h, alice, "artist-profiled", "Profiled")
path := "/api/admin/requests/" + uuidToString(rv.ID) + "/approve"
if resp := doAdminRequestReq(t, h, http.MethodPost, path, []byte(`{"profile_id":"00000000-0000-4000-8000-000000000000"}`), admin); resp.Code != http.StatusBadRequest {
t.Fatalf("unknown profile: %d %s", resp.Code, resp.Body.String())
}
body := []byte(`{"profile_id":"` + uuidToString(profile.ID) + `"}`)
if resp := doAdminRequestReq(t, h, http.MethodPost, path, body, admin); resp.Code != http.StatusOK {
t.Fatalf("approve: %d %s", resp.Code, resp.Body.String())
}
f, err := dbq.New(h.pool).GetFollow(ctx, "artist-profiled")
if err != nil || f.ProfileID != profile.ID || !f.WantExisting {
t.Errorf("follow = %+v, %v: on Albums only, wanting what exists", f, err)
}
}
+340
View File
@@ -0,0 +1,340 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Searching for wants and grabbing releases (M502 #5521): the schedule's
// settings, each want's candidates, and a person's Search, Grab and "never
// this one".
// searchTimeout bounds a search a person waits on: a Prowlarr fanning out to
// many trackers can take most of a minute.
const searchTimeout = 2 * time.Minute
type searchSettingsView struct {
AutoGrab bool `json:"auto_grab"`
SearchIntervalHours int32 `json:"search_interval_hours"`
AllowedQualities []string `json:"allowed_qualities"`
Tiers []release.TierInfo `json:"tiers"`
LastPassAt *string `json:"last_pass_at"`
LastPass json.RawMessage `json:"last_pass"`
// ImportRoot is the library folder new music is filed into (#5522); ""
// is the first of ScanPaths, the library's folders.
ImportRoot string `json:"import_root"`
ScanPaths []string `json:"scan_paths"`
// The download lifecycle (#5523): seed an imported download to this
// ratio or for this many days; give up on one nobody shares after this
// many hours. 0 is: remove once imported, no time limit, never.
SeedRatio float32 `json:"seed_ratio"`
SeedDays int32 `json:"seed_days"`
StallHours int32 `json:"stall_hours"`
// UpgradeUntil is the tier a held album is upgraded to (#5524); "" is
// no quality upgrades.
UpgradeUntil string `json:"upgrade_until"`
}
func searchSettings(s dbq.AcquisitionSetting, roots []string) searchSettingsView {
v := searchSettingsView{
AutoGrab: s.AutoGrab, SearchIntervalHours: s.SearchIntervalHours,
AllowedQualities: s.AllowedQualities, Tiers: release.Tiers, LastPass: s.LastPass,
ImportRoot: s.ImportRoot, ScanPaths: roots,
SeedRatio: s.SeedRatio, SeedDays: s.SeedDays, StallHours: s.StallHours,
UpgradeUntil: s.UpgradeUntil,
}
if v.ScanPaths == nil {
v.ScanPaths = []string{}
}
if v.AllowedQualities == nil {
v.AllowedQualities = []string{}
}
if len(v.LastPass) == 0 {
v.LastPass = json.RawMessage("{}")
}
if s.LastPassAt.Valid {
at := formatTimestamp(s.LastPassAt)
v.LastPassAt = &at
}
return v
}
// handleGetSearchSettings implements GET /api/admin/acquisition/settings.
func (h *handlers) handleGetSearchSettings(w http.ResponseWriter, r *http.Request) {
s, err := dbq.New(h.pool).GetAcquisitionSettings(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin search settings: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
}
type putSearchSettingsBody struct {
AutoGrab bool `json:"auto_grab"`
SearchIntervalHours int32 `json:"search_interval_hours"`
AllowedQualities []string `json:"allowed_qualities"`
ImportRoot string `json:"import_root"`
SeedRatio float32 `json:"seed_ratio"`
SeedDays int32 `json:"seed_days"`
StallHours int32 `json:"stall_hours"`
UpgradeUntil string `json:"upgrade_until"`
}
// handlePutSearchSettings implements PUT /api/admin/acquisition/settings.
func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Request) {
var body putSearchSettingsBody
if !decodeBody(w, r, &body) {
return
}
if body.SearchIntervalHours < 0 || body.SearchIntervalHours > 720 {
writeErr(w, apierror.BadRequest("bad_interval", "search_interval_hours must be between 0 and 720"))
return
}
if len(body.AllowedQualities) == 0 {
writeErr(w, apierror.BadRequest("bad_quality", "allow at least one quality"))
return
}
for _, t := range body.AllowedQualities {
if !release.KnownTier(t) {
writeErr(w, apierror.BadRequest("bad_quality", "unknown quality "+t))
return
}
}
if body.UpgradeUntil != "" && !release.KnownTier(body.UpgradeUntil) {
writeErr(w, apierror.BadRequest("bad_upgrade_until", "unknown quality "+body.UpgradeUntil))
return
}
if body.ImportRoot != "" && !slices.Contains(h.importer.Roots(), body.ImportRoot) {
writeErr(w, apierror.BadRequest("bad_import_root", "file new music into one of the library's folders"))
return
}
if body.SeedRatio < 0 || body.SeedRatio > 100 || body.SeedDays < 0 || body.SeedDays > 365 ||
body.StallHours < 0 || body.StallHours > 720 {
writeErr(w, apierror.BadRequest("bad_lifecycle",
"seed ratio must be 0 to 100, seed days 0 to 365 and stall hours 0 to 720"))
return
}
s, err := dbq.New(h.pool).UpdateAcquisitionSettings(r.Context(), dbq.UpdateAcquisitionSettingsParams{
AutoGrab: body.AutoGrab, SearchIntervalHours: body.SearchIntervalHours, AllowedQualities: body.AllowedQualities,
ImportRoot: body.ImportRoot, SeedRatio: body.SeedRatio, SeedDays: body.SeedDays, StallHours: body.StallHours,
UpgradeUntil: body.UpgradeUntil,
})
if err != nil {
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
}
// candidateView is one release a want's last search found.
type candidateView struct {
Key string `json:"key"`
Name string `json:"name"`
InfoHash string `json:"info_hash"`
Indexer string `json:"indexer"`
SizeBytes int64 `json:"size_bytes"`
Seeders int32 `json:"seeders"`
FilesCount int32 `json:"files_count"`
Format string `json:"format"`
Quality string `json:"quality"`
Year int32 `json:"year"`
Verdict string `json:"verdict"`
Reason string `json:"reason"`
Reasons json.RawMessage `json:"reasons"`
FoldedFrom []string `json:"folded_from"`
SearchedAt string `json:"searched_at"`
Downloading bool `json:"downloading"`
}
// handleListCandidates implements GET /api/admin/wanted/{id}/candidates.
func (h *handlers) handleListCandidates(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
q := dbq.New(h.pool)
rows, err := q.ListWantCandidates(r.Context(), id)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: candidates", apierror.Internal(err))
return
}
live, err := q.GetLiveGrab(r.Context(), id)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
writeErrWithLog(w, h.logger, "admin wanted: live grab", apierror.Internal(err))
return
}
out := make([]candidateView, 0, len(rows))
for _, c := range rows {
v := candidateView{
Key: c.Key, Name: c.Name, InfoHash: c.InfoHash, Indexer: c.Indexer, SizeBytes: c.SizeBytes,
Seeders: c.Seeders, FilesCount: c.FilesCount, Format: c.Format, Quality: c.Quality, Year: c.Year,
Verdict: c.Verdict, Reason: c.Reason, Reasons: c.Reasons, FoldedFrom: c.FoldedFrom,
SearchedAt: formatTimestamp(c.SearchedAt),
Downloading: live.ID.Valid && live.Key == c.Key,
}
if v.FoldedFrom == nil {
v.FoldedFrom = []string{}
}
if len(v.Reasons) == 0 {
v.Reasons = json.RawMessage("[]")
}
out = append(out, v)
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
// handleSearchWant implements POST /api/admin/wanted/{id}/search: search
// now, as the schedule would, taking the best release when auto-grab is on.
func (h *handlers) handleSearchWant(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
defer cancel()
res, err := h.searcher.SearchNow(ctx, id)
if h.writeSearchErr(w, err) {
return
}
writeJSON(w, http.StatusOK, res)
}
type grabBody struct {
Key string `json:"key"`
Force bool `json:"force"`
}
// grabView is what came of a person's grab.
type grabView struct {
Added bool `json:"added"`
Refused bool `json:"refused"`
Gone bool `json:"gone"`
Unconfigured bool `json:"unconfigured"`
Missing string `json:"missing,omitempty"`
Kept bool `json:"kept"`
Replaced bool `json:"replaced"`
Verdict string `json:"verdict,omitempty"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
}
// handleGrabCandidate implements POST /api/admin/wanted/{id}/grab: take the
// release named by key. The search runs again first, so the indexer's link
// never leaves the server; force takes what the verdict would not.
func (h *handlers) handleGrabCandidate(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body grabBody
if !decodeBody(w, r, &body) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
defer cancel()
took, err := h.searcher.Take(ctx, id, body.Key, body.Force, admin.ID)
if h.writeSearchErr(w, err) {
return
}
v := grabView{
Added: took.Added, Refused: took.Refused, Gone: took.Gone, Unconfigured: took.Unconfigured,
Missing: took.Missing, Error: took.Error,
}
if took.Chosen != nil {
v.Verdict, v.Reason = string(took.Chosen.Verdict), took.Chosen.Reason
}
if took.Contest != nil {
v.Kept, v.Replaced = took.Contest.Kept, took.Contest.Replaced
if took.Contest.Kept {
v.Reason = took.Contest.Reason
}
}
writeJSON(w, http.StatusOK, v)
}
type blockBody struct {
Key string `json:"key"`
}
// handleBlockCandidate implements POST /api/admin/wanted/{id}/block: never
// take this release again, for any want.
func (h *handlers) handleBlockCandidate(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body blockBody
if !decodeBody(w, r, &body) {
return
}
c, err := dbq.New(h.pool).GetWantCandidate(r.Context(), dbq.GetWantCandidateParams{WantID: id, Key: body.Key})
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("candidate"))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: block", apierror.Internal(err))
return
}
if c.InfoHash == "" {
writeErr(w, apierror.BadRequest("no_infohash", "this release has no infohash to set aside by"))
return
}
if err := acquire.NewMemory(h.pool).Block(r.Context(), dbq.BlockReleaseParams{
InfoHash: c.InfoHash, Name: c.Name, Reason: acquire.BlockOperator, WantID: id, BlockedBy: admin.ID,
}); err != nil {
writeErrWithLog(w, h.logger, "admin wanted: block", apierror.Internal(err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnblockRelease implements DELETE /api/admin/release-blocks/{hash}.
func (h *handlers) handleUnblockRelease(w http.ResponseWriter, r *http.Request) {
removed, err := acquire.NewMemory(h.pool).Unblock(r.Context(), chi.URLParam(r, "hash"))
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: unblock", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("block"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// writeSearchErr answers a Searcher error, and says whether there was one.
func (h *handlers) writeSearchErr(w http.ResponseWriter, err error) bool {
switch {
case err == nil:
return false
case errors.Is(err, pgx.ErrNoRows):
writeErr(w, apierror.NotFound("want"))
case errors.Is(err, wanted.ErrWantHeld):
writeErr(w, apierror.Conflict("want_held", "the library already holds this album, and it isn't wanted for a better copy"))
default:
writeErrWithLog(w, h.logger, "admin wanted: search", apierror.Internal(err))
}
return true
}
+229
View File
@@ -0,0 +1,229 @@
package api
import (
"net/http"
"syscall"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
)
// The admin Overview's status sections (M504): after the verdict, who is
// listening, what is downloading, what the library holds and how much room
// it has, and how the server itself is doing. One read for the page, polled
// while it is open.
type listeningView struct {
Username string `json:"username"`
TrackID string `json:"track_id"`
Title string `json:"title"`
ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
DurationMs int32 `json:"duration_ms"`
StartedAt string `json:"started_at"`
Client string `json:"client"`
DeviceClass string `json:"device_class"`
}
type listeningStatusView struct {
Now []listeningView `json:"now"`
// The last day: how many plays, by how many people, the latest when.
PlaysDay int64 `json:"plays_day"`
ListenersDay int64 `json:"listeners_day"`
LastPlayAt *string `json:"last_play_at"`
}
type downloadsStatusView struct {
Running int64 `json:"running"`
NeedsYou int64 `json:"needs_you"`
NeedsYouSince *string `json:"needs_you_since"`
ImportedDay int64 `json:"imported_day"`
GivenUpDay int64 `json:"given_up_day"`
// Live is the downloads not yet ended, the ones needing a person first.
Live []downloadView `json:"live"`
ClientsDown []clientDownView `json:"clients_down"`
}
// diskView is the filesystem one library folder, or the data folder, sits
// on.
type diskView struct {
Path string `json:"path"`
// Data marks Minstrel's own data folder (covers, fingerprints), beside
// the library's.
Data bool `json:"data,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
// Error says why the folder could not be read, which is itself the
// news: a drive that did not mount.
Error string `json:"error,omitempty"`
}
type libraryStatusView struct {
Tracks int64 `json:"tracks"`
Albums int64 `json:"albums"`
Artists int64 `json:"artists"`
Bytes int64 `json:"bytes"`
Disks []diskView `json:"disks"`
}
type serverErrorView struct {
At string `json:"at"`
Message string `json:"message"`
Attrs string `json:"attrs"`
}
type serverStatusView struct {
Version string `json:"version"`
Channel string `json:"channel"`
StartedAt string `json:"started_at"`
// Migration is the database schema's version, and Dirty whether its
// last migration stopped part way.
Migration int64 `json:"migration"`
Dirty bool `json:"dirty"`
// Errors is the newest few errors logged since the start, newest first;
// ErrorCount is all of them.
Errors []serverErrorView `json:"errors"`
ErrorCount int64 `json:"error_count"`
}
type adminStatusView struct {
Listening listeningStatusView `json:"listening"`
Downloads downloadsStatusView `json:"downloads"`
Library libraryStatusView `json:"library"`
Server serverStatusView `json:"server"`
}
// overviewLiveDownloads is how many running downloads the Overview lists;
// Activity has the rest.
const overviewLiveDownloads = 5
// overviewErrors is how many recent errors the Overview lists.
const overviewErrors = 10
// handleGetAdminStatus implements GET /api/admin/status.
func (h *handlers) handleGetAdminStatus(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
ctx := r.Context()
var out adminStatusView
now, err := q.ListListeningNow(ctx)
if err != nil {
h.logger.Error("admin status: listening", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out.Listening.Now = make([]listeningView, 0, len(now))
for _, p := range now {
out.Listening.Now = append(out.Listening.Now, listeningView{
Username: p.Username, TrackID: uuidToString(p.TrackID), Title: p.TrackTitle,
ArtistName: p.ArtistName, AlbumID: uuidToString(p.AlbumID), AlbumTitle: p.AlbumTitle,
DurationMs: p.DurationMs, StartedAt: formatTimestamp(p.StartedAt),
Client: deref(p.ClientID), DeviceClass: deref(p.DeviceClass),
})
}
day, err := q.PlaysLastDay(ctx)
if err != nil {
h.logger.Error("admin status: plays", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out.Listening.PlaysDay, out.Listening.ListenersDay = day.Plays, day.Listeners
if day.LastAt.Valid {
at := formatTimestamp(day.LastAt)
out.Listening.LastPlayAt = &at
}
if err := h.downloadsStatus(r, q, &out.Downloads); err != nil {
h.logger.Error("admin status: downloads", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
lib, err := q.LibraryStatus(ctx)
if err != nil {
h.logger.Error("admin status: library", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out.Library = libraryStatusView{Tracks: lib.Tracks, Albums: lib.Albums, Artists: lib.Artists,
Bytes: lib.Bytes, Disks: []diskView{}}
if h.importer != nil {
for _, root := range h.importer.Roots() {
out.Library.Disks = append(out.Library.Disks, diskOf(root))
}
}
if h.dataDir != "" {
d := diskOf(h.dataDir)
d.Data = true
out.Library.Disks = append(out.Library.Disks, d)
}
out.Server = serverStatus(logging.Errors)
// golang-migrate's own table, outside sqlc's schema.
if err := h.pool.QueryRow(ctx, `SELECT version, dirty FROM schema_migrations LIMIT 1`).
Scan(&out.Server.Migration, &out.Server.Dirty); err != nil {
h.logger.Warn("admin status: migration version", "err", err)
}
writeJSON(w, http.StatusOK, out)
}
func (h *handlers) downloadsStatus(r *http.Request, q *dbq.Queries, out *downloadsStatusView) error {
st, err := q.DownloadsStatus(r.Context())
if err != nil {
return err
}
*out = downloadsStatusView{Running: st.Running, NeedsYou: st.NeedsYou,
ImportedDay: st.ImportedDay, GivenUpDay: st.GivenUpDay,
Live: []downloadView{}, ClientsDown: []clientDownView{}}
if st.NeedsYou > 0 && st.NeedsYouSince.Valid {
since := formatTimestamp(st.NeedsYouSince)
out.NeedsYouSince = &since
}
// ListGrabs puts the ones needing a person first, then the running ones.
rows, err := q.ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: overviewLiveDownloads})
if err != nil {
return err
}
for _, g := range rows {
if !g.EndedAt.Valid {
out.Live = append(out.Live, downloadFrom(g))
}
}
down, err := q.ListClientsDown(r.Context())
if err != nil {
return err
}
for _, c := range down {
out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail,
Since: formatTimestamp(c.FirstSeen)})
}
return nil
}
// diskOf reads the size and free space of the filesystem path is on.
func diskOf(path string) diskView {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return diskView{Path: path, Error: err.Error()}
}
bsize := uint64(st.Bsize)
return diskView{Path: path, TotalBytes: st.Blocks * bsize, FreeBytes: st.Bavail * bsize}
}
func serverStatus(ring *logging.ErrorRing) serverStatusView {
kept, total := ring.Snapshot()
v := serverStatusView{Version: buildinfo.Version, Channel: buildinfo.Channel,
StartedAt: buildinfo.Started.UTC().Format(time.RFC3339), ErrorCount: total,
Errors: make([]serverErrorView, 0, min(len(kept), overviewErrors))}
for i, e := range kept {
if i == overviewErrors {
break
}
v.Errors = append(v.Errors, serverErrorView{At: e.At.UTC().Format(time.RFC3339),
Message: e.Message, Attrs: e.Attrs})
}
return v
}
+89
View File
@@ -0,0 +1,89 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
)
// openPlay records a play by user of track that started ago and has not
// ended.
func openPlay(t *testing.T, h *handlers, user, track pgtype.UUID, ago string) {
t.Helper()
ctx := context.Background()
var session pgtype.UUID
if err := h.pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at) VALUES ($1, now(), now()) RETURNING id`,
user).Scan(&session); err != nil {
t.Fatalf("session: %v", err)
}
if _, err := h.pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, client_id)
VALUES ($1, $2, $3, now() - $4::interval, 'web')`, user, track, session, ago); err != nil {
t.Fatalf("play: %v", err)
}
}
// The Overview's status (M504): a play still inside its track's length is
// listening now; one long past it, never closed by its client, is not.
func TestAdminStatus_ListeningAndServer(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
fresh, _ := seedTrackOnDisk(t, h, "statusfresh") // 30 s long
stale, _ := seedTrackOnDisk(t, h, "statusstale")
openPlay(t, h, admin.ID, fresh.ID, "10 seconds")
openPlay(t, h, admin.ID, stale.ID, "1 hour")
req := withUser(httptest.NewRequest(http.MethodGet, "/api/admin/status", nil), admin)
w := httptest.NewRecorder()
h.handleGetAdminStatus(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var got adminStatusView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
var sawFresh, sawStale bool
for _, p := range got.Listening.Now {
switch p.TrackID {
case uuidToString(fresh.ID):
sawFresh = true
if p.Username != admin.Username || p.Client != "web" || p.Title != "AdmQ Track statusfresh" {
t.Errorf("listening row = %+v", p)
}
case uuidToString(stale.ID):
sawStale = true
}
}
if !sawFresh || sawStale {
t.Errorf("listening now: fresh %v (want true), stale %v (want false)", sawFresh, sawStale)
}
if got.Listening.PlaysDay < 2 || got.Listening.LastPlayAt == nil {
t.Errorf("last day: plays %d, last %v", got.Listening.PlaysDay, got.Listening.LastPlayAt)
}
if got.Library.Tracks != 2 || got.Library.Albums != 2 || got.Library.Bytes != 10 {
t.Errorf("library = %+v, want 2 tracks, 2 albums, 10 bytes", got.Library)
}
if got.Downloads.Live == nil || got.Downloads.ClientsDown == nil || got.Library.Disks == nil {
t.Error("lists must be [], never null")
}
if got.Server.Version != buildinfo.Version || got.Server.StartedAt == "" || got.Server.Migration < 92 {
t.Errorf("server = %+v", got.Server)
}
if _, total := logging.Errors.Snapshot(); got.Server.ErrorCount != total {
t.Errorf("error count = %d, ring says %d", got.Server.ErrorCount, total)
}
}
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"net/http"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// adminQueueView is one queue that waits on a person: how many, and since
// when the oldest of them has waited (null when the queue is empty).
type adminQueueView struct {
Count int64 `json:"count"`
Since *string `json:"since"`
}
// adminSummaryView is what the admin rail and the Overview's verdict read
// (#5605): every queue an admin acts on, in one response, so each admin page
// pays one cheap read for its rail.
type adminSummaryView struct {
Requests adminQueueView `json:"requests"`
Reports adminQueueView `json:"reports"`
Duplicates adminQueueView `json:"duplicates"`
Suspect adminQueueView `json:"suspect"`
Missing adminQueueView `json:"missing"`
}
func queueView(count int64, since pgtype.Timestamptz) adminQueueView {
v := adminQueueView{Count: count}
if count > 0 && since.Valid {
s := formatTimestamp(since)
v.Since = &s
}
return v
}
func adminSummaryFrom(row dbq.AdminSummaryRow) adminSummaryView {
return adminSummaryView{
Requests: queueView(row.Requests, row.RequestsSince),
Reports: queueView(row.Reports, row.ReportsSince),
Duplicates: queueView(row.Duplicates, row.DuplicatesSince),
Suspect: queueView(row.Suspect, row.SuspectSince),
Missing: queueView(row.Missing, row.MissingSince),
}
}
// handleGetAdminSummary implements GET /api/admin/summary.
func (h *handlers) handleGetAdminSummary(w http.ResponseWriter, r *http.Request) {
row, err := dbq.New(h.pool).AdminSummary(r.Context())
if err != nil {
h.logger.Error("admin: summary", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
writeJSON(w, http.StatusOK, adminSummaryFrom(row))
}
+92
View File
@@ -0,0 +1,92 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func getAdminSummary(t *testing.T, h *handlers, user dbq.User) (int, adminSummaryView) {
t.Helper()
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/summary", h.handleGetAdminSummary)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, withUser(httptest.NewRequest(http.MethodGet, "/api/admin/summary", nil), user))
var out adminSummaryView
if w.Code == http.StatusOK {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
}
return w.Code, out
}
// The summary counts each queue an admin acts on and says since when the
// oldest item has waited; an empty queue has no since (#5605).
func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) {
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
listener := seedUser(t, h.pool, "listener", "pw", false)
ctx := context.Background()
code, empty := getAdminSummary(t, h, admin)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if empty.Requests.Count != 0 || empty.Requests.Since != nil || empty.Missing.Count != 0 {
t.Errorf("empty summary = %+v", empty)
}
if _, err := dbq.New(h.pool).CreateRequest(ctx, dbq.CreateRequestParams{
UserID: listener.ID, Kind: dbq.RequestKindArtist, ArtistMbid: "a0000000-0000-4000-8000-000000000001",
ArtistName: "Annadale",
}); err != nil {
t.Fatal(err)
}
_, got := getAdminSummary(t, h, admin)
if got.Requests.Count != 1 || got.Requests.Since == nil {
t.Errorf("requests = %+v, want 1 with a since", got.Requests)
}
if code, _ := getAdminSummary(t, h, listener); code != http.StatusForbidden {
t.Errorf("a listener got %d, want 403", code)
}
}
// Duplicates count what needs review: a cross-release group is kept and
// linked by the resolver and waits on nobody (operator, 2026-10-19: the
// headline said 3,479 when 536 needed review).
func TestAdminSummary_DuplicatesCountOnlyWhatNeedsReview(t *testing.T) {
h, _ := testHandlers(t)
truncateLibrary(t, h.pool)
admin := seedUser(t, h.pool, "dupsum-admin", "pw", true)
ctx := context.Background()
a := seedQuarantineTrack(t, h, "dupsum-a")
b := seedQuarantineTrack(t, h, "dupsum-b")
for _, g := range []struct{ key, class string }{{"dupsum-review", "review"}, {"dupsum-cross", "cross_release"}} {
var id [16]byte
if err := h.pool.QueryRow(ctx,
`INSERT INTO duplicate_groups (member_key, tier, class) VALUES ($1, 'acoustic', $2) RETURNING id`,
g.key, g.class).Scan(&id); err != nil {
t.Fatal(err)
}
if _, err := h.pool.Exec(ctx, `INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`,
id, a.ID, b.ID); err != nil {
t.Fatal(err)
}
}
_, got := getAdminSummary(t, h, admin)
if got.Duplicates.Count != 1 {
t.Errorf("duplicates = %d, want 1: the cross-release group needs nobody", got.Duplicates.Count)
}
}
+9
View File
@@ -31,6 +31,10 @@ type suspectTrackView struct {
// first pass (M498 #5439). VerdictAt is when it was last set.
Verdict *string `json:"verdict"`
VerdictAt *string `json:"verdict_at"`
// Replacing: the album is on the wanted list for a clean copy (#5524);
// SearchedAt is when one was last searched for, null before the first.
Replacing bool `json:"replacing"`
SearchedAt *string `json:"searched_at"`
}
// suspectGroupView is a folder's worth of flagged tracks. As on the
@@ -112,6 +116,11 @@ func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGro
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
t.VerdictAt = &at
}
t.Replacing = row.WantUpgrade == "rip"
if t.Replacing && row.SearchedAt.Valid {
at := row.SearchedAt.Time.UTC().Format(time.RFC3339)
t.SearchedAt = &at
}
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
continue
+5 -28
View File
@@ -3,7 +3,6 @@ package api
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
@@ -11,27 +10,22 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// removeTrackResponse is the wire shape from spec §5 (M7 #372). The three
// removeTrackResponse is the wire shape from spec §5 (M7 #372). The two
// optional fields are omitted unless the corresponding cleanup actually
// happened, so the response stays minimal in the common case.
type removeTrackResponse struct {
DeletedTrackID string `json:"deleted_track_id"`
DeletedAlbumID *string `json:"deleted_album_id,omitempty"`
DeletedArtistID *string `json:"deleted_artist_id,omitempty"`
LidarrUnmonitorFailed *bool `json:"lidarr_unmonitor_failed,omitempty"`
}
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}?unmonitor=true|false.
// handleRemoveTrack implements DELETE /api/admin/tracks/{id}.
//
// Admin-only (gated by auth.RequireAdmin on the /admin route group). Deletes the
// file, then the DB row, and runs the album/artist cascade tidy-up — and deletes
// nothing at all when the file cannot be removed (#3918). When
// unmonitor=true and the track has an mbid, also calls Lidarr.UnmonitorTrack
// — failure there is non-fatal (the destructive part already completed) and
// surfaces as `lidarr_unmonitor_failed: true` in the success envelope.
// nothing at all when the file cannot be removed (#3918).
//
// Per spec §5, Lidarr-side errors during the unmonitor step do NOT map to
// wire error codes. The codes this handler emits are not_found,
// The codes this handler emits are not_found,
// library_not_writable (409) and file_delete_failed when the file could not be
// removed, server_error, plus the auth codes the middleware emits upstream.
func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
@@ -46,13 +40,6 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
return
}
unmonitor := false
if v := r.URL.Query().Get("unmonitor"); v != "" {
if parsed, err := strconv.ParseBool(v); err == nil {
unmonitor = parsed
}
}
// Defensive: RequireUser+RequireAdmin should have run upstream. If
// we got here without a user in context the routing is broken.
admin, ok := requireUser(w, r)
@@ -60,9 +47,7 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
return
}
deletedAlbum, deletedArtist, lidarrUnmonitorFailed, err := h.tracks.RemoveTrack(
r.Context(), trackID, admin.ID, unmonitor,
)
deletedAlbum, deletedArtist, err := h.tracks.RemoveTrack(r.Context(), trackID, admin.ID)
if err != nil {
if errors.Is(err, tracks.ErrNotFound) {
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "track not found"})
@@ -87,13 +72,5 @@ func (h *handlers) handleRemoveTrack(w http.ResponseWriter, r *http.Request) {
s := uuidToString(*deletedArtist)
resp.DeletedArtistID = &s
}
// Only emit the flag when unmonitor was requested AND it failed.
// Avoids "false" cluttering the wire when the operator didn't ask
// for an unmonitor in the first place.
if unmonitor && lidarrUnmonitorFailed {
t := true
resp.LidarrUnmonitorFailed = &t
}
writeJSON(w, http.StatusOK, resp)
}
+6 -100
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -15,41 +14,12 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// fakeLidarrUnmonitorer captures UnmonitorTrack calls for assertion and lets
// tests dictate the return error. Used to drive both the Lidarr-success and
// Lidarr-failure branches without spinning up a stub HTTP server.
type fakeLidarrUnmonitorer struct {
calls []fakeUnmonitorCall
err error
}
type fakeUnmonitorCall struct {
trackMbid string
albumMbid string
}
func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error {
f.calls = append(f.calls, fakeUnmonitorCall{trackMbid: trackMbid, albumMbid: albumMbid})
return f.err
}
// installTracksLidarrStub replaces h.tracks with a fresh tracks.Service whose
// LidarrUnmonitorer is the supplied fake. Returns the fake so tests can
// assert call counts and inject error returns.
func installTracksLidarrStub(t *testing.T, h *handlers) *fakeLidarrUnmonitorer {
t.Helper()
fake := &fakeLidarrUnmonitorer{}
h.tracks = tracks.NewService(h.pool, h.logger, fake, h.dataDir)
return fake
}
// newAdminTracksRouter builds a test chi router with the admin-tracks
// endpoint. RequireAdmin middleware is applied; tests inject the user into
// context manually (RequireUser is bypassed). Mirrors the pattern from
// admin_lidarr_test.go and admin_quarantine_test.go.
// admin_quarantine_test.go.
func newAdminTracksRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
@@ -90,8 +60,8 @@ func doAdminTracksReqNoUser(t *testing.T, h *handlers, method, path, idParam str
}
// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
// returns the track row. The mbid string is set on both track and album
// when non-empty so the unmonitor branch has the data it needs.
// returns the track row. The mbids are set on the track and album when
// non-empty.
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "RemoveTest Artist "+unique)
@@ -206,11 +176,9 @@ func TestHandleRemoveTrack_UnknownID404(t *testing.T) {
}
}
// TestHandleRemoveTrack_HappyPath_NoUnmonitor verifies the success envelope
// when the operator does NOT request unmonitor: 200 with deleted_track_id
// (and the optional cascade fields when they fired) but NO
// lidarr_unmonitor_failed key in the body.
func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
// TestHandleRemoveTrack_HappyPath verifies the success envelope: 200 with
// deleted_track_id and the optional cascade fields when they fired.
func TestHandleRemoveTrack_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -237,21 +205,6 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
if got.DeletedArtistID == nil {
t.Error("deleted_artist_id missing; expected sole-track cascade")
}
if got.LidarrUnmonitorFailed != nil {
t.Errorf("lidarr_unmonitor_failed should be omitted when unmonitor not requested; got %v",
*got.LidarrUnmonitorFailed)
}
// Verify the raw JSON has no `lidarr_unmonitor_failed` key — omitempty
// on a *bool drops it, but we double-check the wire to catch
// regressions.
var raw map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode raw: %v", err)
}
if _, ok := raw["lidarr_unmonitor_failed"]; ok {
t.Error("wire body contained lidarr_unmonitor_failed when unmonitor was not requested")
}
// File is gone, DB row is gone.
if _, err := os.Stat(path); !os.IsNotExist(err) {
@@ -261,50 +214,3 @@ func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
t.Error("track row still exists")
}
}
// TestHandleRemoveTrack_UnmonitorFailureFlag verifies that when the
// operator sends ?unmonitor=true AND the Lidarr call fails, the response
// is still 200 (file + DB delete completed) but carries
// lidarr_unmonitor_failed: true.
func TestHandleRemoveTrack_UnmonitorFailureFlag(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
fake := installTracksLidarrStub(t, h)
fake.err = errors.New("lidarr exploded")
tr, path := seedTrackForRemoveTest(t, h, "umon-fail", "track-mbid-x", "album-mbid-x")
w := doAdminTracksReq(t, h, http.MethodDelete,
"/api/admin/tracks/"+uuidToString(tr.ID)+"?unmonitor=true", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got removeTrackResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.DeletedTrackID != uuidToString(tr.ID) {
t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
}
if got.LidarrUnmonitorFailed == nil || !*got.LidarrUnmonitorFailed {
t.Errorf("lidarr_unmonitor_failed = %v, want true", got.LidarrUnmonitorFailed)
}
// Lidarr was actually called with the right mbids.
if len(fake.calls) != 1 {
t.Fatalf("UnmonitorTrack call count = %d, want 1", len(fake.calls))
}
if fake.calls[0].trackMbid != "track-mbid-x" || fake.calls[0].albumMbid != "album-mbid-x" {
t.Errorf("unmonitor call = %+v, want {track-mbid-x album-mbid-x}", fake.calls[0])
}
// Destructive part still happened.
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file still exists: %v", err)
}
if _, err := dbq.New(pool).GetTrackByID(context.Background(), tr.ID); err == nil {
t.Error("track row still exists")
}
}
+820
View File
@@ -0,0 +1,820 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// The wanted list's admin surface (M502 #5520): wants, follows and
// release-type profiles.
// wantView is one row of GET /api/admin/wanted.
type wantView struct {
ID string `json:"id"`
ReleaseGroupMBID string `json:"release_group_mbid"`
Title string `json:"title"`
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
ArtistID string `json:"artist_id,omitempty"`
PrimaryType string `json:"primary_type"`
SecondaryTypes []string `json:"secondary_types"`
Year string `json:"year"`
Reason string `json:"reason"`
RequestedBy string `json:"requested_by,omitempty"`
AlbumID string `json:"album_id,omitempty"`
WantedAt string `json:"wanted_at"`
HeldAt *string `json:"held_at"`
ImageURL string `json:"image_url"`
// Upgrade: why a held album is wanted again, quality or rip, and
// HeldQuality what the library's copy is (#5524).
Upgrade string `json:"upgrade,omitempty"`
HeldQuality string `json:"held_quality,omitempty"`
// Search is the schedule's last look; Grab the download running (#5521).
Search *wantSearchView `json:"search"`
Grab *wantGrabView `json:"grab"`
}
type wantSearchView struct {
SearchedAt string `json:"searched_at"`
Outcome string `json:"outcome"`
Considered int32 `json:"considered"`
BestRelease string `json:"best_release"`
BestVerdict string `json:"best_verdict"`
Error string `json:"error"`
}
// wantGrabView is the want's latest download, running or ended, and what
// its import made of it (#5522).
type wantGrabView struct {
ID string `json:"id"`
Name string `json:"name"`
GrabbedAt string `json:"grabbed_at"`
Origin string `json:"origin"`
Quality string `json:"quality"`
Live bool `json:"live"`
EndReason string `json:"end_reason"`
Progress float32 `json:"progress"`
ImportState string `json:"import_state"`
ImportReason string `json:"import_reason"`
EndDetail string `json:"end_detail"`
ImportedAt *string `json:"imported_at"`
ImportedFiles int32 `json:"imported_files"`
}
// wantCounts sizes each tab of the Wanted page. Artists: how many artists
// the list holds albums of, its main page (#5572).
type wantCounts struct {
Artists int64 `json:"artists"`
// Unmatched: library albums waiting to be matched by hand (#5579).
Unmatched int64 `json:"unmatched"`
Wanted int64 `json:"wanted"`
Missing int64 `json:"missing"`
Held int64 `json:"held"`
// Upgrades: held albums wanted for a better copy (#5524).
Upgrades int64 `json:"upgrades"`
Following int64 `json:"following"`
// Activity: downloads running; Attention: of those, the ones waiting on
// a person; Blocked: releases set aside (#5522).
Activity int64 `json:"activity"`
Attention int64 `json:"attention"`
Blocked int64 `json:"blocked"`
}
type wantsPage struct {
Items []wantView `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
// Library is the one-time listing of the library's albums (#5567).
Library libraryListingView `json:"library"`
// Artist heads one artist's page, when the list is narrowed to them.
Artist *wantArtistView `json:"artist,omitempty"`
}
// wantArtistView is one artist on the Wanted view's main page (#5572): how
// many of their albums the list holds, of how many.
type wantArtistView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
SortName string `json:"sort_name"`
ArtistID string `json:"artist_id,omitempty"`
ImageURL string `json:"image_url"`
Albums int32 `json:"albums"`
Held int32 `json:"held"`
Wanted int32 `json:"wanted"`
Missing int32 `json:"missing"`
Downloading int32 `json:"downloading"`
Following bool `json:"following"`
}
type wantArtistsPage struct {
Items []wantArtistView `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Counts wantCounts `json:"counts"`
Library libraryListingView `json:"library"`
}
// libraryListingView: whether the library's albums are all on the list
// yet, and how many could not be matched to MusicBrainz (#5569); those are
// listed in the Collection's Unmatched section (#5579).
type libraryListingView struct {
ListedAt *string `json:"listed_at"`
Unlisted int32 `json:"unlisted"`
}
// unmatchedAlbumView is one album in the Collection's Unmatched section
// (#5579): why no MusicBrainz album matched it, or that a person left it
// off.
type unmatchedAlbumView struct {
AlbumID string `json:"album_id"`
Title string `json:"title"`
Artist string `json:"artist"`
Detail string `json:"detail"`
Tracks int32 `json:"tracks"`
LeftOff bool `json:"left_off"`
DecidedAt string `json:"decided_at"`
}
// unmatchedShown bounds the Unmatched section: a library a listing could
// not match more of than this has a bigger problem than one page shows.
const unmatchedShown = 500
const wantsPageSize = 50
// wantArtistsPageSize fills a few rows of the artist grid a page.
const wantArtistsPageSize = 96
// wantsHeader is what every Collection page carries: each section's count,
// and how the one-time library listing went.
func (h *handlers) wantsHeader(r *http.Request, q *dbq.Queries) (wantCounts, libraryListingView, error) {
counts, err := q.CountWants(r.Context())
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
listing, err := q.GetLibraryListing(r.Context())
if err != nil {
return wantCounts{}, libraryListingView{}, err
}
lib := libraryListingView{Unlisted: listing.LibraryUnlisted}
if listing.LibraryListedAt.Valid {
at := formatTimestamp(listing.LibraryListedAt)
lib.ListedAt = &at
}
return wantCounts{Artists: counts.Artists, Unmatched: counts.Unmatched, Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held,
Upgrades: counts.Upgrades, Following: counts.Following, Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked}, lib, nil
}
func offsetParam(r *http.Request) int {
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
return max(offset, 0)
}
func wantArtistViewOf(row dbq.ListWantArtistsRow) wantArtistView {
v := wantArtistView{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName, SortName: row.SortName,
ImageURL: artistArtURL(row.ArtistMbid, row.ArtistName),
Albums: row.Albums, Held: row.Held, Wanted: row.Wanted, Missing: row.Missing,
Downloading: row.Downloading, Following: row.Following,
}
if row.LibraryArtistID.Valid {
v.ArtistID = uuidToString(row.LibraryArtistID)
}
return v
}
// handleListWantArtists implements GET /api/admin/wanted/artists?q=&offset=:
// the Wanted view's main page, every artist on the list A–Z (#5572).
func (h *handlers) handleListWantArtists(w http.ResponseWriter, r *http.Request) {
offset := offsetParam(r)
q := dbq.New(h.pool)
rows, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantArtistsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: artists", apierror.Internal(err))
return
}
counts, lib, err := h.wantsHeader(r, q)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
out := wantArtistsPage{Items: make([]wantArtistView, 0, len(rows)), Limit: wantArtistsPageSize, Offset: offset,
Counts: counts, Library: lib}
for _, row := range rows {
out.Total = row.Total
out.Items = append(out.Items, wantArtistViewOf(row))
}
writeJSON(w, http.StatusOK, out)
}
// handleListWants implements
// GET /api/admin/wanted?state=wanted|missing|upgrade|held|all&artist=&q=&offset=.
// wanted is everything open but missing files; upgrade the held albums
// wanted for a better copy (#5524). Each list runs artist by
// artist, A–Z, like the library (#5572); q narrows it to an artist or
// album, and artist to one artist's page, which it then heads.
func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
state = "wanted"
}
if state != "wanted" && state != "missing" && state != "upgrade" && state != "held" && state != "all" {
writeErr(w, apierror.BadRequest("bad_state", "state must be wanted, missing, upgrade, held or all"))
return
}
offset := offsetParam(r)
artist := strings.TrimSpace(r.URL.Query().Get("artist"))
q := dbq.New(h.pool)
rows, err := q.ListWants(r.Context(), dbq.ListWantsParams{State: state, ArtistMbid: artist,
Q: strings.TrimSpace(r.URL.Query().Get("q")), Skip: int32(offset), MaxRows: wantsPageSize})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: list", apierror.Internal(err))
return
}
counts, lib, err := h.wantsHeader(r, q)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: counts", apierror.Internal(err))
return
}
out := wantsPage{Library: lib, Counts: counts,
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset}
if artist != "" {
heads, err := q.ListWantArtists(r.Context(), dbq.ListWantArtistsParams{ArtistMbid: artist, MaxRows: 1})
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: artist", apierror.Internal(err))
return
}
if len(heads) == 0 {
writeErr(w, apierror.NotFound("artist"))
return
}
v := wantArtistViewOf(heads[0])
out.Artist = &v
}
for _, row := range rows {
out.Total = row.Total
out.Items = append(out.Items, wantViewOf(row))
}
writeJSON(w, http.StatusOK, out)
}
// wantViewOf is one ListWants row as the wanted list and the Missing page
// send it.
func wantViewOf(row dbq.ListWantsRow) wantView {
v := wantView{
ID: uuidToString(row.ID), ReleaseGroupMBID: row.ReleaseGroupMbid, Title: row.Title,
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
Year: first4(row.FirstReleaseDate), Reason: row.Reason,
WantedAt: formatTimestamp(row.WantedAt), Upgrade: row.Upgrade, HeldQuality: row.HeldQuality,
ImageURL: groupArtURL(musicbrainz.ReleaseGroup{MBID: row.ReleaseGroupMbid, Title: row.Title, ArtistCredit: row.ArtistName}),
}
if row.LibraryArtistID.Valid {
v.ArtistID = uuidToString(row.LibraryArtistID)
}
if row.AlbumID.Valid {
v.AlbumID = uuidToString(row.AlbumID)
}
if row.RequestedBy != nil {
v.RequestedBy = *row.RequestedBy
}
if row.FulfilledAt.Valid {
held := formatTimestamp(row.FulfilledAt)
v.HeldAt = &held
}
if v.SecondaryTypes == nil {
v.SecondaryTypes = []string{}
}
if row.SearchedAt.Valid {
v.Search = &wantSearchView{
SearchedAt: formatTimestamp(row.SearchedAt), Outcome: row.SearchOutcome,
Considered: row.SearchConsidered, BestRelease: row.BestRelease,
BestVerdict: row.BestVerdict, Error: row.SearchError,
}
}
if row.GrabName != nil {
v.Grab = &wantGrabView{ID: uuidToString(row.GrabID), Name: *row.GrabName,
GrabbedAt: formatTimestamp(row.GrabbedAt), Live: !row.GrabEndedAt.Valid,
Origin: deref(row.GrabOrigin), Quality: deref(row.GrabQuality), EndReason: deref(row.GrabEndReason),
ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason),
EndDetail: deref(row.GrabEndDetail)}
if row.GrabProgress != nil {
v.Grab.Progress = *row.GrabProgress
}
if row.GrabImportedFiles != nil {
v.Grab.ImportedFiles = *row.GrabImportedFiles
}
if row.GrabImportedAt.Valid {
at := formatTimestamp(row.GrabImportedAt)
v.Grab.ImportedAt = &at
}
}
return v
}
// wantTracksView is an album's track list for the Wanted view (#5572): the
// library's own tracks for an album it holds or held, else MusicBrainz's
// standard edition of what is wanted.
type wantTracksView struct {
// library, musicbrainz, or none when MusicBrainz lists no edition.
Source string `json:"source"`
// Edition names the MusicBrainz release the list is from.
Edition string `json:"edition,omitempty"`
Tracks []wantTrackView `json:"tracks"`
}
type wantTrackView struct {
Disc int32 `json:"disc"`
Number int32 `json:"number"`
Title string `json:"title"`
LengthMs int32 `json:"length_ms"`
// Missing: the library's file for it is gone.
Missing bool `json:"missing"`
}
// handleWantTracks implements GET /api/admin/wanted/{id}/tracks.
func (h *handlers) handleWantTracks(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
q := dbq.New(h.pool)
want, err := q.GetWantAlbum(r.Context(), id)
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, apierror.NotFound("want"))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: tracks", apierror.Internal(err))
return
}
out := wantTracksView{Tracks: []wantTrackView{}}
if want.AlbumID.Valid {
rows, err := q.ListLibraryAlbumTracks(r.Context(), want.AlbumID)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: library tracks", apierror.Internal(err))
return
}
out.Source = "library"
for _, t := range rows {
v := wantTrackView{Title: t.Title, LengthMs: t.DurationMs, Missing: t.Missing}
if t.DiscNumber != nil {
v.Disc = *t.DiscNumber
}
if t.TrackNumber != nil {
v.Number = *t.TrackNumber
}
out.Tracks = append(out.Tracks, v)
}
writeJSON(w, http.StatusOK, out)
return
}
eds, err := h.catalogue.Releases(r.Context(), want.ReleaseGroupMbid)
switch {
case errors.Is(err, catalogue.ErrNotFound):
out.Source = "none"
writeJSON(w, http.StatusOK, out)
return
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
writeErr(w, &apierror.Error{Status: 503, Code: "musicbrainz_unreachable", Message: "MusicBrainz can't be reached", Cause: err})
return
case err != nil:
writeErrWithLog(w, h.logger, "admin wanted: editions", apierror.Internal(err))
return
}
e, ok := standardEdition(eds.Editions)
if !ok {
out.Source = "none"
writeJSON(w, http.StatusOK, out)
return
}
out.Source = "musicbrainz"
out.Edition = editionLabel(e.Release)
for _, t := range e.Tracks {
out.Tracks = append(out.Tracks, wantTrackView{Disc: t.MediumPosition, Number: t.Position, Title: t.Title, LengthMs: t.LengthMs})
}
writeJSON(w, http.StatusOK, out)
}
// standardEdition is the edition a person would call the album: an official
// one with the fewest tracks, else any with the fewest. The catalogue lists
// official editions first by date, so a tie goes to the earliest.
func standardEdition(eds []catalogue.Edition) (catalogue.Edition, bool) {
best := -1
for i, e := range eds {
if len(e.Tracks) == 0 {
continue
}
if best < 0 {
best = i
continue
}
b := eds[best]
bo, eo := b.Release.Status == "Official", e.Release.Status == "Official"
if (eo && !bo) || (eo == bo && len(e.Tracks) < len(b.Tracks)) {
best = i
}
}
if best < 0 {
return catalogue.Edition{}, false
}
return eds[best], true
}
// editionLabel names a release for a person: "2023 · CD · GB".
func editionLabel(r dbq.MbRelease) string {
var parts []string
for _, p := range []string{first4(r.ReleaseDate), r.Formats, r.Country} {
if p != "" {
parts = append(parts, p)
}
}
return strings.Join(parts, " · ")
}
func first4(date string) string {
if len(date) < 4 {
return ""
}
return date[:4]
}
// handleRemoveWant implements DELETE /api/admin/wanted/{id}.
func (h *handlers) handleRemoveWant(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
removed, err := h.wanted.RemoveWant(r.Context(), id)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: remove", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("want"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// followView is a followed artist.
type followView struct {
ArtistMBID string `json:"artist_mbid"`
ArtistName string `json:"artist_name"`
ArtistID string `json:"artist_id,omitempty"`
ProfileID string `json:"profile_id"`
ProfileName string `json:"profile_name"`
FollowedAt string `json:"followed_at"`
CheckedAt *string `json:"checked_at"`
OpenWants int64 `json:"open_wants"`
ImageURL string `json:"image_url"`
}
// handleListFollows implements GET /api/admin/follows.
func (h *handlers) handleListFollows(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListFollows(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: list", apierror.Internal(err))
return
}
out := make([]followView, 0, len(rows))
for _, f := range rows {
v := followView{
ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName,
ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
FollowedAt: formatTimestamp(f.FollowedAt), OpenWants: f.OpenWants,
ImageURL: artistArtURL(f.ArtistMbid, f.ArtistName),
}
if f.ArtistID.Valid {
v.ArtistID = uuidToString(f.ArtistID)
}
if f.CheckedAt.Valid {
checked := formatTimestamp(f.CheckedAt)
v.CheckedAt = &checked
}
out = append(out, v)
}
writeJSON(w, http.StatusOK, out)
}
// followStateView answers whether one artist is followed, for the control on
// an artist page.
type followStateView struct {
Following bool `json:"following"`
ProfileID string `json:"profile_id,omitempty"`
ProfileName string `json:"profile_name,omitempty"`
}
// handleGetFollow implements GET /api/admin/follows/{mbid}.
func (h *handlers) handleGetFollow(w http.ResponseWriter, r *http.Request) {
f, err := dbq.New(h.pool).GetFollow(r.Context(), chi.URLParam(r, "mbid"))
if errors.Is(err, pgx.ErrNoRows) {
writeJSON(w, http.StatusOK, followStateView{})
return
}
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, followStateView{
Following: true, ProfileID: uuidToString(f.ProfileID), ProfileName: f.ProfileName,
})
}
type putFollowBody struct {
ArtistName string `json:"artist_name"`
ProfileID string `json:"profile_id"`
WantExisting bool `json:"want_existing"`
}
// handlePutFollow implements PUT /api/admin/follows/{mbid}: follow an artist,
// or change the profile it is followed on. want_existing also wants what the
// artist has already released that the profile passes and isn't held.
func (h *handlers) handlePutFollow(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
var body putFollowBody
if !decodeBody(w, r, &body) {
return
}
var profile pgtype.UUID
if body.ProfileID != "" {
if profile, ok = parseUUID(body.ProfileID); !ok {
writeErr(w, apierror.BadRequest("bad_profile", "profile_id is not an id"))
return
}
}
mbid := chi.URLParam(r, "mbid")
if _, err := h.wanted.Follow(r.Context(), wanted.FollowParams{
ArtistMBID: mbid, ArtistName: body.ArtistName, ProfileID: profile,
By: admin.ID, WantExisting: body.WantExisting,
}); err != nil {
switch {
case errors.Is(err, wanted.ErrNotFollowable):
writeErr(w, apierror.BadRequest("not_followable", "Various Artists and MusicBrainz placeholders can't be followed"))
case errors.Is(err, wanted.ErrProfileNotFound):
writeErr(w, apierror.BadRequest("profile_not_found", "no such profile"))
default:
writeErrWithLog(w, h.logger, "admin follows: put", apierror.Internal(err))
}
return
}
h.handleGetFollow(w, r)
}
// handleDeleteFollow implements DELETE /api/admin/follows/{mbid}. What the
// follow already wanted stays on the list.
func (h *handlers) handleDeleteFollow(w http.ResponseWriter, r *http.Request) {
removed, err := h.wanted.Unfollow(r.Context(), chi.URLParam(r, "mbid"))
if err != nil {
writeErrWithLog(w, h.logger, "admin follows: delete", apierror.Internal(err))
return
}
if !removed {
writeErr(w, apierror.NotFound("follow"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// releaseProfileView is a release-type profile.
type releaseProfileView struct {
ID string `json:"id"`
Name string `json:"name"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
IsDefault bool `json:"is_default"`
FollowCount int64 `json:"follow_count"`
}
// releaseProfilesResponse also carries MusicBrainz's types, so the editor
// offers exactly what a profile may hold.
type releaseProfilesResponse struct {
Profiles []releaseProfileView `json:"profiles"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
}
// handleListReleaseProfiles implements GET /api/admin/release-profiles.
func (h *handlers) handleListReleaseProfiles(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListReleaseTypeProfiles(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin release profiles: list", apierror.Internal(err))
return
}
out := releaseProfilesResponse{
Profiles: make([]releaseProfileView, 0, len(rows)),
PrimaryTypes: wanted.PrimaryTypes, SecondaryTypes: wanted.SecondaryTypes,
}
for _, p := range rows {
out.Profiles = append(out.Profiles, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault, FollowCount: p.FollowCount,
})
}
writeJSON(w, http.StatusOK, out)
}
type releaseProfileBody struct {
Name string `json:"name"`
PrimaryTypes []string `json:"primary_types"`
SecondaryTypes []string `json:"secondary_types"`
IsDefault bool `json:"is_default"`
}
func (b releaseProfileBody) input() wanted.ProfileInput {
return wanted.ProfileInput{
Name: b.Name, PrimaryTypes: b.PrimaryTypes, SecondaryTypes: b.SecondaryTypes, IsDefault: b.IsDefault,
}
}
// handleCreateReleaseProfile implements POST /api/admin/release-profiles.
func (h *handlers) handleCreateReleaseProfile(w http.ResponseWriter, r *http.Request) {
var body releaseProfileBody
if !decodeBody(w, r, &body) {
return
}
p, err := h.wanted.CreateProfile(r.Context(), body.input())
if err != nil {
h.writeProfileErr(w, err)
return
}
writeJSON(w, http.StatusCreated, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
})
}
// handleUpdateReleaseProfile implements PUT /api/admin/release-profiles/{id}.
// A follow on the profile judges new releases by the new types from its next
// check; releases it has already judged are not judged again.
func (h *handlers) handleUpdateReleaseProfile(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body releaseProfileBody
if !decodeBody(w, r, &body) {
return
}
p, err := h.wanted.UpdateProfile(r.Context(), id, body.input())
if err != nil {
h.writeProfileErr(w, err)
return
}
writeJSON(w, http.StatusOK, releaseProfileView{
ID: uuidToString(p.ID), Name: p.Name, PrimaryTypes: p.PrimaryTypes,
SecondaryTypes: p.SecondaryTypes, IsDefault: p.IsDefault,
})
}
// handleDeleteReleaseProfile implements DELETE /api/admin/release-profiles/{id}.
func (h *handlers) handleDeleteReleaseProfile(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if err := h.wanted.DeleteProfile(r.Context(), id); err != nil {
h.writeProfileErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handlers) writeProfileErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, wanted.ErrProfileInvalid):
writeErr(w, apierror.BadRequest("profile_invalid", err.Error()))
case errors.Is(err, wanted.ErrProfileNotFound):
writeErr(w, apierror.NotFound("profile"))
case errors.Is(err, wanted.ErrProfileNameUsed):
writeErr(w, apierror.Conflict("profile_name_taken", "a profile already has that name"))
case errors.Is(err, wanted.ErrProfileInUse):
writeErr(w, apierror.Conflict("profile_in_use", "artists are followed on this profile; move them first"))
case errors.Is(err, wanted.ErrProfileDefault):
writeErr(w, apierror.Conflict("profile_is_default", "the default profile can't be deleted"))
default:
writeErrWithLog(w, h.logger, "admin release profiles", apierror.Internal(err))
}
}
// deref is a nullable column's text, or "".
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// handleListUnmatched implements GET /api/admin/wanted/unmatched: the
// Collection's Unmatched section (#5579), the albums no MusicBrainz album
// matched and, after them, the ones a person left off.
func (h *handlers) handleListUnmatched(w http.ResponseWriter, r *http.Request) {
rows, err := dbq.New(h.pool).ListUnmatchedLibraryAlbums(r.Context(), unmatchedShown)
if err != nil {
writeErrWithLog(w, h.logger, "admin wanted: unmatched", apierror.Internal(err))
return
}
out := struct {
Items []unmatchedAlbumView `json:"items"`
}{Items: make([]unmatchedAlbumView, 0, len(rows))}
for _, u := range rows {
out.Items = append(out.Items, unmatchedAlbumView{
AlbumID: uuidToString(u.AlbumID), Title: u.Title, Artist: u.ArtistName, Detail: u.Detail,
Tracks: u.Tracks, LeftOff: u.Outcome == "dismissed", DecidedAt: formatTimestamp(u.DecidedAt),
})
}
writeJSON(w, http.StatusOK, out)
}
type manualMatchView struct {
ReleaseGroupMBID string `json:"release_group_mbid"`
Title string `json:"title"`
Artist string `json:"artist"`
Agree int `json:"agree"`
Tracks int `json:"tracks"`
}
// handleMatchUnmatched implements POST /api/admin/wanted/unmatched/{id}/match
// {"ref": "<MusicBrainz link or id>"}: the album goes on the list as held,
// as that MusicBrainz album.
func (h *handlers) handleMatchUnmatched(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
var body struct {
Ref string `json:"ref"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "expected {\"ref\": \"…\"}"))
return
}
m, err := h.wanted.MatchAlbumTo(r.Context(), id, body.Ref)
switch {
case errors.Is(err, wanted.ErrMatchRef):
writeErr(w, apierror.BadRequest("bad_ref", "That isn't a MusicBrainz album link or id."))
return
case errors.Is(err, wanted.ErrMatchNoGroup):
writeErr(w, &apierror.Error{Status: http.StatusUnprocessableEntity, Code: "no_such_album",
Message: "MusicBrainz has no album at that link.", Cause: err})
return
case errors.Is(err, wanted.ErrAlbumNotUnmatched):
writeErr(w, apierror.NotFound("unmatched album"))
return
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
writeErr(w, &apierror.Error{Status: http.StatusServiceUnavailable, Code: "musicbrainz_unreachable",
Message: "MusicBrainz can't be reached", Cause: err})
return
case err != nil:
writeErrWithLog(w, h.logger, "admin wanted: match by hand", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, manualMatchView{ReleaseGroupMBID: m.Group, Title: m.Title, Artist: m.Artist,
Agree: m.Agree, Tracks: m.Tracks})
}
// handleLeaveUnmatchedOff implements POST /api/admin/wanted/unmatched/{id}/leave-off,
// and handlePutUnmatchedBack its undoing, POST …/{id}/put-back.
func (h *handlers) handleLeaveUnmatchedOff(w http.ResponseWriter, r *http.Request) {
h.setUnmatched(w, r, h.wanted.LeaveAlbumOff)
}
func (h *handlers) handlePutUnmatchedBack(w http.ResponseWriter, r *http.Request) {
h.setUnmatched(w, r, h.wanted.PutAlbumBack)
}
func (h *handlers) setUnmatched(w http.ResponseWriter, r *http.Request, set func(context.Context, pgtype.UUID) error) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
err := set(r.Context(), id)
switch {
case errors.Is(err, wanted.ErrAlbumNotUnmatched):
writeErr(w, apierror.NotFound("unmatched album"))
case err != nil:
writeErrWithLog(w, h.logger, "admin wanted: unmatched", apierror.Internal(err))
default:
w.WriteHeader(http.StatusNoContent)
}
}
+371
View File
@@ -0,0 +1,371 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
func doWantedReq(t *testing.T, h *handlers, path string, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
return doWanted(t, h, http.MethodGet, path, "", user)
}
func doWanted(t *testing.T, h *handlers, method, path, body string, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/wanted", h.handleListWants)
admin.Get("/wanted/artists", h.handleListWantArtists)
admin.Get("/wanted/unmatched", h.handleListUnmatched)
admin.Post("/wanted/unmatched/{id}/match", h.handleMatchUnmatched)
admin.Post("/wanted/unmatched/{id}/leave-off", h.handleLeaveUnmatchedOff)
admin.Post("/wanted/unmatched/{id}/put-back", h.handlePutUnmatchedBack)
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, withUser(httptest.NewRequest(method, path, strings.NewReader(body)), user))
return w
}
func seedWant(t *testing.T, h *handlers, group, artistMBID, artist, title, date string) dbq.Want {
t.Helper()
w, err := wanted.WantWith(context.Background(), dbq.New(h.pool), wanted.WantParams{
GroupMBID: group, ArtistMBID: artistMBID, ArtistName: artist, Title: title,
PrimaryType: "Album", FirstReleaseDate: date, Reason: wanted.ReasonFollow,
})
if err != nil {
t.Fatal(err)
}
return w
}
func listWanted(t *testing.T, h *handlers, user dbq.User, q string) wantsPage {
t.Helper()
w := doWantedReq(t, h, "/api/admin/wanted?"+url.Values{"q": {q}}.Encode(), user)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var page wantsPage
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
return page
}
// The list runs like the library (#5572): artists A–Z by the library's sort
// name where the library holds the artist, each artist's albums oldest
// first; q narrows it to an artist or an album.
func TestListWants_ArtistsAToZThenTheirAlbumsByDate(t *testing.T) {
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
beatles := "a0000000-0000-4000-8000-000000000002"
if _, err := dbq.New(h.pool).UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000001", "a0000000-0000-4000-8000-000000000003", "Zeds", "Zed Album", "2001")
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969-09-26")
seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963-03-22")
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
page := listWanted(t, h, admin, "")
var got []string
for _, it := range page.Items {
got = append(got, it.ArtistName+"/"+it.Title)
}
want := "[The Beatles/Please Please Me The Beatles/Abbey Road Camel/Moonmadness Zeds/Zed Album]"
if fmt.Sprint(got) != want {
t.Errorf("order = %v, want %s", got, want)
}
if page.Items[0].ArtistID == "" || page.Items[2].ArtistID != "" {
t.Errorf("artist ids = %q, %q: only an artist the library holds has one", page.Items[0].ArtistID, page.Items[2].ArtistID)
}
page = listWanted(t, h, admin, "BEATLES")
if len(page.Items) != 2 || page.Total != 2 {
t.Errorf("q=BEATLES: %d items, total %d; want the two Beatles albums", len(page.Items), page.Total)
}
page = listWanted(t, h, admin, "moon")
if len(page.Items) != 1 || page.Items[0].Title != "Moonmadness" || page.Total != 1 {
t.Errorf("q=moon: %+v, total %d", page.Items, page.Total)
}
if page = listWanted(t, h, admin, "nothing like it"); len(page.Items) != 0 || page.Total != 0 {
t.Errorf("no match: %d items, total %d", len(page.Items), page.Total)
}
}
// The Collection's catalogue (#5572): every artist on the list A–Z, each
// with how many of their albums are held; an artist's page lists every album
// of theirs, held or not, under that artist.
func TestListWantArtists_CatalogueAndAnArtistsPage(t *testing.T) {
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
beatles := "a0000000-0000-4000-8000-000000000002"
camel := "a0000000-0000-4000-8000-000000000001"
if _, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "The Beatles", SortName: "Beatles, The", Mbid: &beatles}); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000002", beatles, "The Beatles", "Abbey Road", "1969")
held := seedWant(t, h, "g0000000-0000-4000-8000-000000000003", beatles, "The Beatles", "Please Please Me", "1963")
if _, err := h.pool.Exec(ctx, `UPDATE wants SET fulfilled_at = now() WHERE id = $1`, held.ID); err != nil {
t.Fatal(err)
}
seedWant(t, h, "g0000000-0000-4000-8000-000000000004", camel, "Camel", "Moonmadness", "1976")
if _, err := h.pool.Exec(ctx, `INSERT INTO follows (artist_mbid, artist_name, profile_id)
SELECT $1, 'Camel', id FROM release_type_profiles WHERE is_default`, camel); err != nil {
t.Fatal(err)
}
w := doWantedReq(t, h, "/api/admin/wanted/artists", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var page wantArtistsPage
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.Total != 2 || page.Counts.Artists != 2 {
t.Fatalf("catalogue = %+v, total %d, artists %d", page.Items, page.Total, page.Counts.Artists)
}
b, c := page.Items[0], page.Items[1]
if b.ArtistName != "The Beatles" || b.SortName != "Beatles, The" || b.Albums != 2 || b.Held != 1 || b.Wanted != 1 ||
b.ArtistID == "" || b.Following || b.ImageURL == "" {
t.Errorf("Beatles = %+v", b)
}
if c.ArtistName != "Camel" || c.Albums != 1 || c.Held != 0 || !c.Following || c.ArtistID != "" {
t.Errorf("Camel = %+v", c)
}
// q finds an artist by an album's title.
w = doWantedReq(t, h, "/api/admin/wanted/artists?q=moon", admin)
page = wantArtistsPage{}
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].ArtistName != "Camel" {
t.Errorf("q=moon: %+v", page.Items)
}
// The Beatles' page: both albums, held or not, headed by the artist.
w = doWantedReq(t, h, "/api/admin/wanted?state=all&artist="+beatles, admin)
var albums wantsPage
if err := json.Unmarshal(w.Body.Bytes(), &albums); err != nil {
t.Fatal(err)
}
if len(albums.Items) != 2 || albums.Items[0].Title != "Please Please Me" || albums.Items[0].HeldAt == nil ||
albums.Artist == nil || albums.Artist.Held != 1 {
t.Errorf("artist page = %+v, head %+v", albums.Items, albums.Artist)
}
if w := doWantedReq(t, h, "/api/admin/wanted?state=all&artist=nobody", admin); w.Code != http.StatusNotFound {
t.Errorf("an artist with nothing on the list: status %d, want 404", w.Code)
}
}
// fakeEditions is a MusicBrainz holding one group with two editions, the
// shorter official one the standard, or 503 when down is set.
func fakeEditions(t *testing.T, group string, down bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if down {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
switch {
case r.URL.Path == "/release-group/"+group:
_, _ = fmt.Fprintf(w, `{"id": %q, "title": "Moonmadness", "primary-type": "Album", "first-release-date": "1976"}`, group)
case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == group:
_, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [
{"id": "r-deluxe", "title": "Moonmadness", "status": "Official", "date": "2009", "country": "GB",
"media": [{"position": 1, "format": "CD", "tracks": [
{"position": 1, "number": "1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}},
{"position": 3, "number": "3", "title": "Bonus", "length": 200000, "recording": {"id": "rec-3"}}]}]},
{"id": "r-orig", "title": "Moonmadness", "status": "Official", "date": "1976-03", "country": "GB",
"media": [{"position": 1, "format": "12\" Vinyl", "tracks": [
{"position": 1, "number": "A1", "title": "Aristillus", "length": 120000, "recording": {"id": "rec-1"}},
{"position": 2, "number": "A2", "title": "Song Within a Song", "length": 433000, "recording": {"id": "rec-2"}}]}]}]}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
func wantTracks(t *testing.T, h *handlers, user dbq.User, id string) (int, wantTracksView) {
t.Helper()
w := doWantedReq(t, h, "/api/admin/wanted/"+id+"/tracks", user)
var out wantTracksView
if w.Code == http.StatusOK {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
}
return w.Code, out
}
// An album not yet held shows MusicBrainz's standard edition: the shortest
// official one.
func TestWantTracks_StandardEditionFromMusicBrainz(t *testing.T) {
const group = "g0000000-0000-4000-8000-000000000010"
for _, down := range []bool{false, true} {
h, pool := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
h.catalogue = catalogue.New(pool, &musicbrainz.Client{BaseURL: fakeEditions(t, group, down).URL,
Pace: &musicbrainz.Pacer{}, Retries: -1}, h.logger)
w := seedWant(t, h, group, "a0000000-0000-4000-8000-000000000001", "Camel", "Moonmadness", "1976")
code, out := wantTracks(t, h, admin, uuidToString(w.ID))
if down {
if code != http.StatusServiceUnavailable {
t.Errorf("MusicBrainz down: status %d, want 503", code)
}
continue
}
if code != http.StatusOK || out.Source != "musicbrainz" || len(out.Tracks) != 2 {
t.Fatalf("status %d, %+v: want the two-track original", code, out)
}
if out.Edition != "1976 · 12\" Vinyl · GB" {
t.Errorf("edition = %q", out.Edition)
}
if tr := out.Tracks[1]; tr.Disc != 1 || tr.Number != 2 || tr.Title != "Song Within a Song" || tr.LengthMs != 433000 {
t.Errorf("track 2 = %+v", tr)
}
}
}
// A held album shows the library's own tracks, a lost file marked.
func TestWantTracks_HeldAlbumShowsTheLibrarysTracks(t *testing.T) {
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
mbid := "a0000000-0000-4000-8000-000000000001"
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Camel", SortName: "Camel", Mbid: &mbid})
if err != nil {
t.Fatal(err)
}
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Moonmadness", SortTitle: "Moonmadness", ArtistID: artist.ID})
if err != nil {
t.Fatal(err)
}
for i, title := range []string{"Aristillus", "Song Within a Song"} {
num := int32(i + 1)
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 120000,
FilePath: fmt.Sprintf("/music/camel/%02d.flac", i+1), FileSize: 1, FileFormat: "flac",
}); err != nil {
t.Fatal(err)
}
}
if _, err := h.pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE title = 'Aristillus'`); err != nil {
t.Fatal(err)
}
w := seedWant(t, h, "g0000000-0000-4000-8000-000000000011", mbid, "Camel", "Moonmadness", "1976")
if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album.ID}); err != nil {
t.Fatal(err)
}
code, out := wantTracks(t, h, admin, uuidToString(w.ID))
if code != http.StatusOK || out.Source != "library" || len(out.Tracks) != 2 {
t.Fatalf("status %d, %+v", code, out)
}
if !out.Tracks[0].Missing || out.Tracks[1].Missing || out.Tracks[1].Number != 2 {
t.Errorf("tracks = %+v: the first is missing, the second on disk", out.Tracks)
}
}
// The Unmatched section (#5579): the albums matching could not place, a
// local band's album left off and put back, and a link that names no
// MusicBrainz album refused before MusicBrainz is asked.
func TestUnmatched_ListLeaveOffPutBack(t *testing.T) {
h, _ := testHandlers(t)
admin := seedUser(t, h.pool, "admin", "pw", true)
ctx := context.Background()
q := dbq.New(h.pool)
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Annadale", SortName: "Annadale"})
if err != nil {
t.Fatal(err)
}
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Dial Tone", SortTitle: "Dial Tone", ArtistID: artist.ID})
if err != nil {
t.Fatal(err)
}
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: "Intro", AlbumID: album.ID, ArtistID: artist.ID, DurationMs: 120000,
FilePath: "/music/annadale/01.flac", FileSize: 1, FileFormat: "flac",
}); err != nil {
t.Fatal(err)
}
if err := q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
AlbumID: album.ID, Outcome: "unmatched", Detail: "MusicBrainz has no album by that name", Matcher: 1}); err != nil {
t.Fatal(err)
}
id := uuidToString(album.ID)
list := func() []unmatchedAlbumView {
t.Helper()
w := doWantedReq(t, h, "/api/admin/wanted/unmatched", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var out struct {
Items []unmatchedAlbumView `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out.Items
}
if got := list(); len(got) != 1 || got[0].AlbumID != id || got[0].Artist != "Annadale" || got[0].Tracks != 1 ||
got[0].LeftOff || got[0].Detail == "" || got[0].DecidedAt == "" {
t.Fatalf("unmatched = %+v", got)
}
for _, c := range []struct {
path, body string
code int
}{
{"/api/admin/wanted/unmatched/" + id + "/match", `{"ref":"Dial Tone"}`, http.StatusBadRequest},
{"/api/admin/wanted/unmatched/" + id + "/match", `nope`, http.StatusBadRequest},
{"/api/admin/wanted/unmatched/not-a-uuid/leave-off", "", http.StatusBadRequest},
{"/api/admin/wanted/unmatched/" + id + "/put-back", "", http.StatusNotFound},
{"/api/admin/wanted/unmatched/" + id + "/leave-off", "", http.StatusNoContent},
{"/api/admin/wanted/unmatched/" + id + "/leave-off", "", http.StatusNotFound},
} {
if w := doWanted(t, h, http.MethodPost, c.path, c.body, admin); w.Code != c.code {
t.Errorf("POST %s %s = %d, want %d; body = %s", c.path, c.body, w.Code, c.code, w.Body.String())
}
}
if got := list(); len(got) != 1 || !got[0].LeftOff {
t.Fatalf("left off = %+v", got)
}
if w := doWanted(t, h, http.MethodPost, "/api/admin/wanted/unmatched/"+id+"/put-back", "", admin); w.Code != http.StatusNoContent {
t.Fatalf("put back = %d; body = %s", w.Code, w.Body.String())
}
if got := list(); len(got) != 1 || got[0].LeftOff {
t.Errorf("put back = %+v", got)
}
user := seedUser(t, h.pool, "listener", "pw", false)
if w := doWantedReq(t, h, "/api/admin/wanted/unmatched", user); w.Code != http.StatusForbidden {
t.Errorf("a listener reads the unmatched section: %d", w.Code)
}
}
+122 -22
View File
@@ -13,30 +13,34 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/lifecycle"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, requestSvc *requests.Service, quarantineSvc *quarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService, acoustIDLookup *library.AcoustIDLookupWorker) {
rng := rand.New(rand.NewSource(rand.Int63()))
setupToken, err := auth.NewSetupToken()
if err != nil {
@@ -45,13 +49,13 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
panic("api: mint setup token: " + err.Error())
}
logSetupTokenIfNeeded(pool, logger, setupToken)
cat := catalogue.New(pool, nil, logger)
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
recSettings: recSettings,
rng: rng.Float64,
lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs,
lidarrQuarantine: lidarrQuar,
requests: requestSvc,
quarantine: quarantineSvc,
tracks: tracksSvc,
playlists: playlistsSvc,
coverart: coverEnricher,
@@ -70,6 +74,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
fingerprintSettings: fpSettings,
loudnessSettings: loudSettings,
acoustIDLookup: acoustIDLookup,
acquisition: acquire.NewStore(pool),
catalogue: cat,
wanted: wanted.New(pool, cat, logger),
searcher: wanted.NewSearcher(pool, cat, logger),
importer: importer.New(pool, scanner, cat, nil, scannerRoots(scanner), logger),
lifecycle: lifecycle.New(pool, logger),
catalogueSearches: newSearchCache(),
catalogueArt: catalogue.NewArt(dataDir, artSources(coverSettings)),
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
librarySize: recommendation.NewLibrarySize(nil),
loginGuard: auth.NewLoginGuard(),
@@ -182,7 +194,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/likes/artists", h.handleListLikedArtists)
authed.Get("/likes/ids", h.handleGetLikedIDs)
authed.Get("/lidarr/search", h.handleLidarrSearch)
// The release catalogue (M502 #5519): request search asks
// MusicBrainz, and art for what isn't held comes through
// Minstrel from the enabled cover-art sources.
authed.Get("/catalogue/search", h.handleCatalogueSearch)
authed.Get("/catalogue/art/{kind}/{mbid}", h.handleCatalogueArt)
authed.Post("/requests", h.handleCreateRequest)
authed.Get("/requests", h.handleListRequests)
@@ -211,23 +227,62 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Route("/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
admin.Get("/acquisition/services", h.handleListAcquisitionServices)
admin.Post("/acquisition/services", h.handleCreateAcquisitionService)
admin.Put("/acquisition/services/{code}", h.handleUpdateAcquisitionService)
admin.Delete("/acquisition/services/{code}", h.handleDeleteAcquisitionService)
admin.Post("/acquisition/services/{code}/test", h.handleTestAcquisitionService)
admin.Put("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
admin.Delete("/acquisition/services/{code}/api-key", h.handleSetAcquisitionSecret("api_key"))
admin.Put("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
admin.Delete("/acquisition/services/{code}/password", h.handleSetAcquisitionSecret("password"))
// The queues that wait on an admin, for the rail and the Overview (#5605).
admin.Get("/summary", h.handleGetAdminSummary)
admin.Get("/status", h.handleGetAdminStatus)
admin.Get("/requests", h.handleListAdminRequests)
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
// The wanted list (M502 #5520): what Minstrel is looking
// for, the artists it follows, and the release-type
// profiles that decide which of their releases count.
admin.Get("/wanted", h.handleListWants)
admin.Get("/wanted/artists", h.handleListWantArtists)
admin.Get("/wanted/unmatched", h.handleListUnmatched)
admin.Post("/wanted/unmatched/{id}/match", h.handleMatchUnmatched)
admin.Post("/wanted/unmatched/{id}/leave-off", h.handleLeaveUnmatchedOff)
admin.Post("/wanted/unmatched/{id}/put-back", h.handlePutUnmatchedBack)
admin.Delete("/wanted/{id}", h.handleRemoveWant)
// Searching and grabbing (M502 #5521).
admin.Get("/acquisition/settings", h.handleGetSearchSettings)
admin.Put("/acquisition/settings", h.handlePutSearchSettings)
admin.Get("/wanted/{id}/candidates", h.handleListCandidates)
admin.Get("/wanted/{id}/tracks", h.handleWantTracks)
admin.Post("/wanted/{id}/search", h.handleSearchWant)
admin.Post("/wanted/{id}/grab", h.handleGrabCandidate)
admin.Post("/wanted/{id}/block", h.handleBlockCandidate)
admin.Delete("/release-blocks/{hash}", h.handleUnblockRelease)
admin.Get("/release-blocks", h.handleListReleaseBlocks)
admin.Get("/downloads", h.handleListDownloads)
admin.Post("/downloads/{id}/import", h.handleImportAnyway)
admin.Post("/downloads/{id}/retry", h.handleRetryImport)
admin.Post("/downloads/{id}/refuse", h.handleRefuseDownload)
admin.Post("/downloads/{id}/abandon", h.handleAbandonDownload)
admin.Get("/follows", h.handleListFollows)
admin.Get("/follows/{mbid}", h.handleGetFollow)
admin.Put("/follows/{mbid}", h.handlePutFollow)
admin.Delete("/follows/{mbid}", h.handleDeleteFollow)
admin.Get("/release-profiles", h.handleListReleaseProfiles)
admin.Post("/release-profiles", h.handleCreateReleaseProfile)
admin.Put("/release-profiles/{id}", h.handleUpdateReleaseProfile)
admin.Delete("/release-profiles/{id}", h.handleDeleteReleaseProfile)
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Get("/playback-errors", h.handleListAdminPlaybackErrors)
admin.Post("/playback-errors/{id}/resolve", h.handleResolvePlaybackError)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
@@ -238,13 +293,17 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/network-settings", h.handleGetNetworkSettings)
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
// Policy for turning a missing file back into a Lidarr
// request (#290). Sits beside the missing-files list it
// governs rather than under /lidarr, because the operator
// meets it on the missing-files surface.
// Policy for wanting back an album whose files went missing
// (#290). Sits beside the missing-files list it governs,
// because the operator meets it on that surface.
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
// Where the library lives (#6129). A changed folder moves its
// tracks rather than re-adding them.
admin.Get("/library/folders", h.handleGetLibraryFolders)
admin.Put("/library/folders", h.handlePutLibraryFolders)
admin.Get("/scan/status", h.handleGetScanStatus)
admin.Post("/scan/run", h.handleTriggerScan)
// Sits under /library rather than /tracks because what it
@@ -274,6 +333,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
admin.Post("/library/duplicates/{id}/refetch", h.handleRefetchDuplicateGroup)
admin.Get("/invites", h.handleListInvites)
admin.Post("/invites", h.handleCreateInvite)
@@ -341,9 +401,8 @@ type handlers struct {
// (#3880). Held here rather than counted per request: the count is a
// full table scan, and library size only moves when a scan runs.
librarySize *recommendation.LibrarySize
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service
requests *requests.Service
quarantine *quarantine.Service
tracks *tracks.Service
playlists *playlists.Service
coverart *coverart.Enricher
@@ -372,6 +431,26 @@ type handlers struct {
// acoustIDLookup is the AcoustID lookup worker (M401): its settings, its
// status and "look up now". Nil serves the defaults (off).
acoustIDLookup *library.AcoustIDLookupWorker
// acquisition is the operator's indexers and download clients (M502).
acquisition *acquire.Store
// catalogue is the release catalogue read from MusicBrainz, and
// catalogueArt the images for what isn't held (M502 #5519).
catalogue *catalogue.Service
catalogueArt *catalogue.Art
// wanted is the wanted list: follows, release-type profiles and wants
// (M502 #5520). Its worker runs in main; the API reads and edits.
wanted *wanted.Service
// searcher searches for wants and grabs releases for them (#5521).
searcher *wanted.Searcher
// importer refuses a download at a person's word (#5522). The import
// loop itself runs in main; this one only acts on a request.
importer *importer.Importer
// lifecycle gives up on a download at a person's word (#5523); its loop
// runs in main.
lifecycle *lifecycle.Service
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
// time, which is what handler tests get.
catalogueSearches *searchCache
// leveled renders the leveled streams handed to Sonos/UPnP speakers
// (M464 #5001). Nil when its cache directory cannot be made: a level
// request then gets the plain stream.
@@ -414,3 +493,24 @@ func newLeveledRenderer(dataDir string, settings *library.LoudnessSettingsServic
}
return r
}
// artSources hands the art cache the cover-art settings, or nothing when
// there are none: a nil *SettingsService inside the interface would not
// compare equal to nil and would be called.
func artSources(s *coverart.SettingsService) catalogue.ArtSources {
if s == nil {
return nil
}
return s
}
// scannerRoots reads the library folders, or none when the API runs without
// a scanner (tests).
func scannerRoots(s *library.Scanner) func() []string {
return func() []string {
if s == nil {
return nil
}
return s.Roots()
}
}
+1 -1
View File
@@ -133,7 +133,7 @@ func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error {
// errNoPublicURL stops a reset email from going out before the operator has
// said where Minstrel lives. It surfaces in the log, not the response, which
// stays the same opaque 200 either way.
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin → Integrations → Public address); reset email not sent")
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin › Email & network › Public address); reset email not sent")
// buildResetURL builds the emailed link from the operator-set public URL.
// It deliberately ignores the request: the Host header is whatever the
+9 -11
View File
@@ -18,20 +18,21 @@ import (
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
@@ -64,16 +65,13 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
if err != nil {
t.Fatalf("recsettings: %v", err)
}
lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil, "")
// tracks.Service has no Lidarr unmonitorer in tests by default; the
// admin-tracks tests below override h.tracks via installTracksLidarrStub
// when they need a stubbed Lidarr.
requestSvc := requests.NewService(pool)
cat := catalogue.New(pool, nil, logger)
quarantineSvc := quarantine.NewService(pool, "")
dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
tracksSvc := tracks.NewService(pool, logger, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), quarantine: quarantineSvc, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
return h, pool
}
+283
View File
@@ -0,0 +1,283 @@
package api
import (
"context"
"errors"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
)
// catalogueSearchLimit is how many results one search shows.
const catalogueSearchLimit = 25
// catalogueSearchResult is one row of GET /api/catalogue/search, read by the
// Discover page and request rows. artist_mbid and album_mbid are the parents a
// request needs: empty on an artist, the artist on an album, both on a track.
// An album is a MusicBrainz release group.
type catalogueSearchResult struct {
MBID string `json:"mbid"`
Name string `json:"name"`
SecondaryText string `json:"secondary_text"`
ImageURL string `json:"image_url"`
ArtistMBID string `json:"artist_mbid"`
AlbumMBID string `json:"album_mbid"`
InLibrary bool `json:"in_library"`
Requested bool `json:"requested"`
}
// handleCatalogueSearch implements GET /api/catalogue/search?q=&kind=artist|album|track
// (M502 #5519), asking MusicBrainz directly. An unreachable MusicBrainz is a
// 503 the page shows as such (rule 164).
func (h *handlers) handleCatalogueSearch(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
switch kind {
case "artist", "album", "track":
default:
writeErr(w, apierror.BadRequest("bad_kind", "kind must be artist, album, or track"))
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeErr(w, apierror.BadRequest("missing_query", "q is required"))
return
}
out, err := h.catalogueSearches.get(r.Context(), kind, q, h.searchCatalogue)
if err != nil {
switch {
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
writeErr(w, &apierror.Error{Status: 503, Code: "musicbrainz_unreachable", Message: "MusicBrainz can't be reached", Cause: err})
case r.Context().Err() != nil:
return
default:
h.logger.Error("api: catalogue search", "kind", kind, "err", err)
writeErr(w, apierror.InternalMsg("search failed", err))
}
return
}
dbQ := dbq.New(h.pool)
for i := range out {
inLib, err := h.catalogueInLibrary(r.Context(), kind, out[i].MBID)
if err != nil {
writeErr(w, apierror.InternalMsg("library check failed", err))
return
}
requested, err := dbQ.HasNonTerminalRequestForMBID(r.Context(), out[i].MBID)
if err != nil {
writeErr(w, apierror.InternalMsg("request check failed", err))
return
}
out[i].InLibrary, out[i].Requested = inLib, requested
}
writeJSON(w, http.StatusOK, out)
}
func (h *handlers) searchCatalogue(ctx context.Context, kind, q string) ([]catalogueSearchResult, error) {
mb := h.catalogue.MusicBrainz()
out := []catalogueSearchResult{}
switch kind {
case "artist":
artists, err := mb.SearchArtists(ctx, q, catalogueSearchLimit)
if err != nil {
return nil, err
}
for _, a := range artists {
out = append(out, catalogueSearchResult{
MBID: a.MBID, Name: a.Name, SecondaryText: artistLine(a),
ImageURL: artistArtURL(a.MBID, a.Name),
})
}
case "album":
groups, err := mb.SearchReleaseGroups(ctx, q, catalogueSearchLimit)
if err != nil {
return nil, err
}
for _, g := range groups {
out = append(out, catalogueSearchResult{
MBID: g.MBID, Name: g.Title, SecondaryText: joinNonEmpty(g.ArtistCredit, g.Year(), groupType(g)),
ImageURL: groupArtURL(g), ArtistMBID: first(g.ArtistMBIDs),
})
}
case "track":
recs, err := mb.SearchRecordings(ctx, q, catalogueSearchLimit)
if err != nil {
return nil, err
}
for _, rec := range recs {
res := catalogueSearchResult{
MBID: rec.MBID, Name: rec.Title,
SecondaryText: joinNonEmpty(rec.ReleaseGroup.Title, rec.ArtistCredit),
ArtistMBID: first(rec.ArtistMBIDs), AlbumMBID: rec.ReleaseGroup.MBID,
}
if rec.ReleaseGroup.MBID != "" {
res.ImageURL = groupArtURL(rec.ReleaseGroup)
}
out = append(out, res)
}
}
return out, nil
}
// catalogueSearchTTL is how long a search's MusicBrainz answer is reused.
// MusicBrainz allows a request a second, and Discover searches as the user
// types, so the same words are often asked again within moments.
const catalogueSearchTTL = 10 * time.Minute
// catalogueSearchCacheMax bounds the cache; past it, the cache starts over.
const catalogueSearchCacheMax = 500
// searchCache keeps recent MusicBrainz answers by kind and query. A nil
// cache asks every time. Whether a result is held or requested is not
// cached: the handler works that out on every request.
type searchCache struct {
mu sync.Mutex
entries map[string]searchEntry
now func() time.Time
}
type searchEntry struct {
at time.Time
results []catalogueSearchResult
}
func newSearchCache() *searchCache {
return &searchCache{entries: map[string]searchEntry{}, now: time.Now}
}
func (c *searchCache) get(ctx context.Context, kind, q string,
ask func(context.Context, string, string) ([]catalogueSearchResult, error),
) ([]catalogueSearchResult, error) {
if c == nil {
return ask(ctx, kind, q)
}
key := kind + "\x00" + strings.ToLower(q)
c.mu.Lock()
e, ok := c.entries[key]
c.mu.Unlock()
if ok && c.now().Sub(e.at) < catalogueSearchTTL {
return slices.Clone(e.results), nil
}
results, err := ask(ctx, kind, q)
if err != nil {
return nil, err
}
c.mu.Lock()
if len(c.entries) >= catalogueSearchCacheMax {
c.entries = map[string]searchEntry{}
}
c.entries[key] = searchEntry{at: c.now(), results: slices.Clone(results)}
c.mu.Unlock()
return results, nil
}
// artistLine is what tells two artists of one name apart: MusicBrainz's own
// disambiguation when it has one, else the kind of artist and the country.
func artistLine(a musicbrainz.Artist) string {
if a.Disambiguation != "" {
return a.Disambiguation
}
return joinNonEmpty(a.Type, a.Country)
}
// groupType names an album's kind the way people do: "Album", "EP",
// "Album · Live", "Single · Remix".
func groupType(g musicbrainz.ReleaseGroup) string {
return joinNonEmpty(append([]string{g.PrimaryType}, g.SecondaryTypes...)...)
}
func joinNonEmpty(parts ...string) string {
kept := parts[:0:0]
for _, p := range parts {
if p != "" {
kept = append(kept, p)
}
}
return strings.Join(kept, " · ")
}
func first(s []string) string {
if len(s) == 0 {
return ""
}
return s[0]
}
// artistArtURL and groupArtURL point at Minstrel's own art endpoints. The
// names ride along for the sources that look art up by name.
func artistArtURL(mbid, name string) string {
if mbid == "" {
return ""
}
return "/api/catalogue/art/artist/" + mbid + "?" + url.Values{"name": {name}}.Encode()
}
func groupArtURL(g musicbrainz.ReleaseGroup) string {
return "/api/catalogue/art/release-group/" + g.MBID + "?" +
url.Values{"artist": {g.ArtistCredit}, "title": {g.Title}}.Encode()
}
// catalogueInLibrary reports whether a result is already held: an artist by
// its MBID, an album by its release group (or a release id that happens to be
// the same), a track by its recording.
func (h *handlers) catalogueInLibrary(ctx context.Context, kind, mbid string) (bool, error) {
var query string
switch kind {
case "artist":
query = `SELECT EXISTS(SELECT 1 FROM artists WHERE mbid = $1)`
case "album":
query = `SELECT EXISTS(SELECT 1 FROM albums WHERE release_group_mbid = $1 OR mbid = $1)`
case "track":
query = `SELECT EXISTS(SELECT 1 FROM tracks WHERE mbid = $1)`
default:
return false, nil
}
var exists bool
err := h.pool.QueryRow(ctx, query, mbid).Scan(&exists)
return exists, err
}
// handleCatalogueArt implements GET /api/catalogue/art/{kind}/{mbid} for an
// artist or a release group Minstrel doesn't hold. The image comes from the
// operator's enabled cover-art sources and is kept on disk, so the browser
// never fetches a third-party host. A 404 shows the placeholder.
func (h *handlers) handleCatalogueArt(w http.ResponseWriter, r *http.Request) {
kind, mbid := chi.URLParam(r, "kind"), chi.URLParam(r, "mbid")
if !catalogue.ValidMBID(mbid) {
writeErr(w, apierror.NotFound("art"))
return
}
var (
body []byte
err error
)
switch kind {
case "artist":
body, err = h.catalogueArt.Artist(r.Context(), mbid, r.URL.Query().Get("name"))
case "release-group":
body, err = h.catalogueArt.ReleaseGroup(r.Context(), mbid, r.URL.Query().Get("artist"), r.URL.Query().Get("title"))
default:
writeErr(w, apierror.NotFound("art"))
return
}
if err != nil {
if r.Context().Err() == nil {
writeErr(w, apierror.NotFound("art"))
}
return
}
w.Header().Set("Content-Type", http.DetectContentType(body))
w.Header().Set("Cache-Control", "private, max-age=86400")
_, _ = w.Write(body)
}
+253
View File
@@ -0,0 +1,253 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
)
const (
searchArtistMBID = "a74b1b7f-71a5-4011-9441-d0b5e4122711"
searchGroupMBID = "b1392450-e666-3926-a536-22c65f834433"
searchRecMBID = "c1a0e1b2-0000-4000-8000-000000000001"
)
// fakeMusicBrainz answers the three searches with one Radiohead result each,
// or 503 when down is set.
func fakeMusicBrainz(t *testing.T, down bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if down {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
credit := `[{"name": "Radiohead", "artist": {"id": "` + searchArtistMBID + `"}}]`
group := `{"id": "` + searchGroupMBID + `", "title": "OK Computer", "primary-type": "Album",
"first-release-date": "1997-05-21", "artist-credit": ` + credit + `}`
switch r.URL.Path {
case "/artist":
_, _ = w.Write([]byte(`{"artists": [{"id": "` + searchArtistMBID + `", "name": "Radiohead", "type": "Group", "country": "GB"}]}`))
case "/release-group":
_, _ = w.Write([]byte(`{"release-groups": [` + group + `]}`))
case "/recording":
_, _ = w.Write([]byte(`{"recordings": [{"id": "` + searchRecMBID + `", "title": "Karma Police",
"artist-credit": ` + credit + `,
"releases": [{"id": "r1", "status": "Official", "release-group": ` + group + `}]}]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
func catalogueHandlers(t *testing.T, down bool) *handlers {
t.Helper()
h, pool := testHandlers(t)
resetRequests(t, h)
mb := &musicbrainz.Client{BaseURL: fakeMusicBrainz(t, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
h.catalogue = catalogue.New(pool, mb, h.logger)
return h
}
func doCatalogueSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/catalogue/search?"+url.Values{"q": {q}, "kind": {kind}}.Encode(), nil)
w := httptest.NewRecorder()
h.handleCatalogueSearch(w, req)
return w
}
func decodeResults(t *testing.T, w *httptest.ResponseRecorder) []catalogueSearchResult {
t.Helper()
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var out []catalogueSearchResult
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
return out
}
func errorCode(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
return resp.Error.Code
}
func TestCatalogueSearch_RefusesABadKindOrNoQuery(t *testing.T) {
h := catalogueHandlers(t, false)
if w := doCatalogueSearch(t, h, "x", "playlist"); w.Code != http.StatusBadRequest || errorCode(t, w) != "bad_kind" {
t.Errorf("bad kind: %d %s", w.Code, w.Body.String())
}
if w := doCatalogueSearch(t, h, " ", "artist"); w.Code != http.StatusBadRequest || errorCode(t, w) != "missing_query" {
t.Errorf("no query: %d %s", w.Code, w.Body.String())
}
}
func TestCatalogueSearch_EachKindAsksMusicBrainz(t *testing.T) {
h := catalogueHandlers(t, false)
artists := decodeResults(t, doCatalogueSearch(t, h, "radiohead", "artist"))
if len(artists) != 1 || artists[0].MBID != searchArtistMBID || artists[0].SecondaryText != "Group · GB" {
t.Fatalf("artists = %+v", artists)
}
if artists[0].ImageURL != "/api/catalogue/art/artist/"+searchArtistMBID+"?name=Radiohead" {
t.Errorf("artist art = %q: it must be Minstrel's own endpoint", artists[0].ImageURL)
}
albums := decodeResults(t, doCatalogueSearch(t, h, "ok computer", "album"))
if len(albums) != 1 || albums[0].MBID != searchGroupMBID || albums[0].ArtistMBID != searchArtistMBID ||
albums[0].SecondaryText != "Radiohead · 1997 · Album" {
t.Fatalf("albums = %+v", albums)
}
tracks := decodeResults(t, doCatalogueSearch(t, h, "karma police", "track"))
if len(tracks) != 1 || tracks[0].AlbumMBID != searchGroupMBID || tracks[0].ArtistMBID != searchArtistMBID ||
tracks[0].SecondaryText != "OK Computer · Radiohead" {
t.Fatalf("tracks = %+v", tracks)
}
}
func TestCatalogueSearch_MarksWhatIsHeldAndWhatIsRequested(t *testing.T) {
h := catalogueHandlers(t, false)
ctx := context.Background()
q := dbq.New(h.pool)
artistMBID := searchArtistMBID
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: dbtest.TestUserPrefix + "Radiohead", SortName: "Radiohead", Mbid: &artistMBID})
if err != nil {
t.Fatal(err)
}
// The album is held under a release id; its release group is what matches.
if _, err := h.pool.Exec(ctx,
`INSERT INTO albums (title, sort_title, artist_id, mbid, release_group_mbid) VALUES ('OK Computer', 'OK Computer', $1, 'some-release-id', $2)`,
artist.ID, searchGroupMBID); err != nil {
t.Fatal(err)
}
if got := decodeResults(t, doCatalogueSearch(t, h, "radiohead", "artist")); !got[0].InLibrary {
t.Error("the artist is held")
}
if got := decodeResults(t, doCatalogueSearch(t, h, "ok computer", "album")); !got[0].InLibrary {
t.Error("the album is held, matched by release group")
}
user := seedUser(t, h.pool, "requester", "pw", false)
track := searchRecMBID
if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: user.ID, Kind: dbq.RequestKindTrack, ArtistMbid: searchArtistMBID,
TrackMbid: &track, ArtistName: "Radiohead",
}); err != nil {
t.Fatal(err)
}
got := decodeResults(t, doCatalogueSearch(t, h, "karma police", "track"))
if got[0].InLibrary || !got[0].Requested {
t.Errorf("track = %+v, want requested and not held", got[0])
}
}
func TestCatalogueSearch_UnreachableMusicBrainzIs503(t *testing.T) {
h := catalogueHandlers(t, true)
w := doCatalogueSearch(t, h, "radiohead", "artist")
if w.Code != http.StatusServiceUnavailable || errorCode(t, w) != "musicbrainz_unreachable" {
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
}
}
// artSource is an artist-art source with one image.
type artSource struct{ apiTestAlbumProvider }
func (artSource) FetchArtistArt(_ context.Context, _ coverart.ArtistRef) ([]byte, []byte, error) {
return []byte("\x89PNG\r\n\x1a\nrest"), nil, nil
}
type fakeArtSources struct{ artists []coverart.ArtistArtProvider }
func (s fakeArtSources) EnabledArtistProviders() []coverart.ArtistArtProvider { return s.artists }
func (fakeArtSources) EnabledAlbumProviders() []coverart.AlbumCoverProvider { return nil }
func TestCatalogueArt_ServesTheImageOrAPlaceholder404(t *testing.T) {
h := &handlers{catalogueArt: catalogue.NewArt(t.TempDir(), fakeArtSources{artists: []coverart.ArtistArtProvider{&artSource{}}})}
r := chi.NewRouter()
r.Get("/api/catalogue/art/{kind}/{mbid}", h.handleCatalogueArt)
get := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w
}
if w := get("/api/catalogue/art/artist/" + searchArtistMBID + "?name=Radiohead"); w.Code != http.StatusOK || w.Header().Get("Content-Type") != "image/png" {
t.Errorf("artist: %d %q", w.Code, w.Header().Get("Content-Type"))
}
// No album source is enabled: a 404, which the page shows as its placeholder.
if w := get("/api/catalogue/art/release-group/" + searchGroupMBID); w.Code != http.StatusNotFound {
t.Errorf("release group with no source: %d", w.Code)
}
for _, bad := range []string{"/api/catalogue/art/artist/not-an-id", "/api/catalogue/art/label/" + searchArtistMBID} {
if w := get(bad); w.Code != http.StatusNotFound {
t.Errorf("%s: %d", bad, w.Code)
}
}
}
func TestSuggestionArt_PointsAtMinstrelsOwnEndpoint(t *testing.T) {
views := []suggestionView{{MBID: searchArtistMBID, Name: "Sigur Rós"}, {Name: "No MBID"}}
resolveSuggestionArt(views)
if views[0].ImageURL != "/api/catalogue/art/artist/"+searchArtistMBID+"?name=Sigur+R%C3%B3s" {
t.Errorf("image_url = %q", views[0].ImageURL)
}
if views[1].ImageURL != "" {
t.Errorf("no MBID, no image: %q", views[1].ImageURL)
}
}
func TestSearchCache_ReusesAnAnswerButNotTheFlags(t *testing.T) {
c := newSearchCache()
now := time.Now()
c.now = func() time.Time { return now }
asks := 0
ask := func(context.Context, string, string) ([]catalogueSearchResult, error) {
asks++
return []catalogueSearchResult{{MBID: "m"}}, nil
}
first, _ := c.get(context.Background(), "artist", "Radiohead", ask)
first[0].InLibrary = true // the handler sets flags on what it is given
again, _ := c.get(context.Background(), "artist", "radiohead", ask)
if asks != 1 {
t.Errorf("asks = %d: the same words in another case are the same search", asks)
}
if again[0].InLibrary {
t.Error("a flag set on one answer leaked into the cache")
}
if _, _ = c.get(context.Background(), "album", "radiohead", ask); asks != 2 {
t.Error("another kind is another search")
}
now = now.Add(catalogueSearchTTL + time.Second)
if _, _ = c.get(context.Background(), "artist", "radiohead", ask); asks != 3 {
t.Error("an old answer is asked again")
}
var none *searchCache
if _, _ = none.get(context.Background(), "artist", "x", ask); asks != 4 {
t.Error("a nil cache asks every time")
}
}
+9
View File
@@ -67,3 +67,12 @@ func writeJSON(w http.ResponseWriter, status int, body any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
// writeAdminJSONErr writes a flat {"error":"<code>"} JSON response. This
// mirrors the shape the RequireAdmin middleware uses and that the spec
// defines for /api/admin/* errors.
func writeAdminJSONErr(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
}
+4 -4
View File
@@ -48,8 +48,8 @@ func (h *handlers) publishLikeEvent(userID, entityID pgtype.UUID, entityType str
}
// publishQuarantineEvent broadcasts a quarantine action. User-side
// flag/unflag are scoped to the user; admin-side resolve / delete-file /
// delete-via-lidarr are broadcast (empty UserID) because a single admin
// flag/unflag are scoped to the user; admin-side resolve / delete-file
// are broadcast (empty UserID) because a single admin
// action can affect every user who'd flagged that track — every client
// invalidates and the non-affected ones no-op when the query result
// matches their cache.
@@ -103,12 +103,12 @@ func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
})
}
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
// publishRequestStatusChanged broadcasts a request status flip to
// the request's original requester so their /requests page reflects the
// new state without manual refresh. Admin actors (approve / reject) still
// route to the requester's user_id, not the admin's — the original user
// is who needs to see the update.
func (h *handlers) publishRequestStatusChanged(row dbq.LidarrRequest) {
func (h *handlers) publishRequestStatusChanged(row dbq.Request) {
if h.eventbus == nil {
return
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
// removeFailure builds the error a delete service returns when the file would
// not go, wrapped the way lidarrquarantine.DeleteFile and tracks.RemoveTrack
// not go, wrapped the way quarantine.DeleteFile and tracks.RemoveTrack
// wrap it — the mapping has to see through that.
func removeFailure(errno syscall.Errno) error {
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
+3
View File
@@ -132,6 +132,9 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
Albums: refs,
Genres: nonNilStrings(genres),
}
if artist.Mbid != nil {
detail.MBID = *artist.Mbid
}
writeJSON(w, http.StatusOK, detail)
}
+2 -2
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.requests, h.quarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings, nil, nil, nil, nil)
paths := []string{
"/api/artists",
@@ -561,7 +561,7 @@ func TestGetArtistTracks_HonorsQuarantine(t *testing.T) {
FileSize: 1, FileFormat: "mp3",
})
if _, err := pool.Exec(context.Background(),
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
`INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
user.ID, track.ID,
); err != nil {
t.Fatalf("quarantine insert: %v", err)
-138
View File
@@ -1,138 +0,0 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
)
// lidarrSearchResult is the JSON shape returned by GET /api/lidarr/search.
// Field names follow the lowercase snake_case convention used by all /api/*
// responses. artist_mbid and album_mbid are the parent identifiers surfaced
// by LookupAlbum / LookupTrack; they are empty strings on artist results.
type lidarrSearchResult struct {
MBID string `json:"mbid"`
Name string `json:"name"`
SecondaryText string `json:"secondary_text"`
ImageURL string `json:"image_url"`
ArtistMBID string `json:"artist_mbid"`
AlbumMBID string `json:"album_mbid"`
InLibrary bool `json:"in_library"`
Requested bool `json:"requested"`
}
// handleLidarrSearch implements GET /api/lidarr/search?q=&kind=artist|album|track.
//
// Steps:
// 1. Validate kind and q params.
// 2. Load lidarr config; 503 when disabled.
// 3. Call the matching Lidarr lookup.
// 4. For each result, compute in_library (EXISTS by MBID) and requested
// (HasNonTerminalRequestForMBID).
// 5. Return normalized JSON array.
func (h *handlers) handleLidarrSearch(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
switch kind {
case "artist", "album", "track":
// valid
default:
writeErr(w, apierror.BadRequest("bad_kind", "kind must be artist, album, or track"))
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeErr(w, apierror.BadRequest("missing_query", "q is required"))
return
}
cfg, err := h.lidarrCfg.Get(r.Context())
if err != nil {
h.logger.Error("api: lidarr search: load config", "err", err)
writeErr(w, &apierror.Error{Status: 500, Code: "lidarr_lookup_failed", Message: "failed to load lidarr config", Cause: err})
return
}
if !cfg.Enabled {
writeErr(w, &apierror.Error{Status: 503, Code: "lidarr_disabled", Message: "Lidarr integration is not enabled"})
return
}
client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
var results []lidarr.LookupResult
switch kind {
case "artist":
results, err = client.LookupArtist(r.Context(), q)
case "album":
results, err = client.LookupAlbum(r.Context(), q)
case "track":
results, err = client.LookupTrack(r.Context(), q)
}
if err != nil {
switch {
case errors.Is(err, lidarr.ErrUnreachable):
writeErr(w, &apierror.Error{Status: 503, Code: "lidarr_unreachable", Message: "Lidarr is unreachable", Cause: err})
case errors.Is(err, lidarr.ErrAuthFailed):
writeErr(w, &apierror.Error{Status: 503, Code: "lidarr_auth_failed", Message: "Lidarr authentication failed", Cause: err})
default:
h.logger.Error("api: lidarr search: lookup failed", "kind", kind, "err", err)
writeErr(w, &apierror.Error{Status: 500, Code: "lidarr_lookup_failed", Message: "Lidarr lookup failed", Cause: err})
}
return
}
dbQ := dbq.New(h.pool)
out := make([]lidarrSearchResult, 0, len(results))
for _, res := range results {
inLib, libErr := h.lidarrInLibrary(r.Context(), kind, res.MBID)
if libErr != nil {
h.logger.Error("api: lidarr search: in_library check failed", "err", libErr)
writeErr(w, &apierror.Error{Status: 500, Code: "lidarr_lookup_failed", Message: "library check failed", Cause: libErr})
return
}
requested, reqErr := dbQ.HasNonTerminalRequestForMBID(r.Context(), res.MBID)
if reqErr != nil {
h.logger.Error("api: lidarr search: requested check failed", "err", reqErr)
writeErr(w, &apierror.Error{Status: 500, Code: "lidarr_lookup_failed", Message: "request check failed", Cause: reqErr})
return
}
out = append(out, lidarrSearchResult{
MBID: res.MBID,
Name: res.Name,
SecondaryText: res.Secondary,
ImageURL: res.ImageURL,
ArtistMBID: res.ArtistMBID,
AlbumMBID: res.AlbumMBID,
InLibrary: inLib,
Requested: requested,
})
}
writeJSON(w, http.StatusOK, out)
}
// lidarrInLibrary checks whether the given MBID exists in the corresponding
// local library table based on kind. Runs a direct EXISTS query since
// these per-kind MBID lookups are not in the sqlc-generated query set.
func (h *handlers) lidarrInLibrary(ctx context.Context, kind, mbid string) (bool, error) {
var query string
switch kind {
case "artist":
query = `SELECT EXISTS(SELECT 1 FROM artists WHERE mbid = $1)`
case "album":
query = `SELECT EXISTS(SELECT 1 FROM albums WHERE mbid = $1)`
case "track":
query = `SELECT EXISTS(SELECT 1 FROM tracks WHERE mbid = $1)`
default:
return false, nil
}
var exists bool
if err := h.pool.QueryRow(ctx, query, mbid).Scan(&exists); err != nil {
return false, err
}
return exists, nil
}
-327
View File
@@ -1,327 +0,0 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// resetLidarrState clears lidarr_requests rows and resets lidarr_config
// to the factory-default state (enabled=false, nulls) between tests.
func resetLidarrState(t *testing.T, h *handlers) {
t.Helper()
ctx := context.Background()
if _, err := h.pool.Exec(ctx,
"DELETE FROM lidarr_requests; UPDATE lidarr_config SET enabled=false, base_url=NULL, api_key=NULL WHERE id=1",
); err != nil {
t.Fatalf("resetLidarrState: %v", err)
}
}
// saveLidarrConfig writes a test lidarr_config row pointing at the given stub URL.
func saveLidarrConfig(t *testing.T, h *handlers, baseURL string, enabled bool) {
t.Helper()
svc := lidarrconfig.New(h.pool)
err := svc.Save(context.Background(), lidarrconfig.Config{
Enabled: enabled,
BaseURL: baseURL,
APIKey: "test-key",
DefaultQualityProfileID: 1,
DefaultRootFolderPath: "/music",
})
if err != nil {
t.Fatalf("saveLidarrConfig: %v", err)
}
}
// lidarrArtistStubBody returns a minimal Lidarr artist lookup JSON payload.
func lidarrArtistStubBody(mbid, name string) string {
return fmt.Sprintf(`[{"foreignArtistId":%q,"artistName":%q,"genres":["Rock"],"albumCount":5,"images":[]}]`, mbid, name)
}
// newLidarrStub creates an httptest.Server that always returns the given body
// with 200 OK for any /api/v1/* path.
func newLidarrStub(t *testing.T, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
// seedArtistWithMBID inserts an artist row with a specific MBID for in_library testing.
func seedArtistWithMBID(t *testing.T, h *handlers, name, mbid string) dbq.Artist {
t.Helper()
a, err := dbq.New(h.pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: name,
SortName: name,
Mbid: &mbid,
})
if err != nil {
t.Fatalf("seedArtistWithMBID: %v", err)
}
return a
}
// seedRequestForMBID inserts a pending lidarr_requests row for the given MBID.
func seedRequestForMBID(t *testing.T, h *handlers, userID pgtype.UUID, kind dbq.LidarrRequestKind, artistMBID, mbid string) {
t.Helper()
var albumMBID, trackMBID *string
switch kind {
case dbq.LidarrRequestKindAlbum:
albumMBID = &mbid
case dbq.LidarrRequestKindTrack:
trackMBID = &mbid
}
_, err := dbq.New(h.pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
UserID: userID,
Kind: kind,
LidarrArtistMbid: artistMBID,
LidarrAlbumMbid: albumMBID,
LidarrTrackMbid: trackMBID,
ArtistName: "Test Artist",
AlbumTitle: nil,
TrackTitle: nil,
})
if err != nil {
t.Fatalf("seedRequestForMBID: %v", err)
}
}
// doLidarrSearch fires a GET /api/lidarr/search request directly at the handler.
// It simulates the RequireUser middleware by injecting a dummy user context.
func doLidarrSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder {
t.Helper()
url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", q, kind)
req := httptest.NewRequest(http.MethodGet, url, nil)
// Inject a minimal user context the same way other handler tests do it.
// The lidarr search handler doesn't use the user from context, so any
// non-nil user value keeps the handler from panicking.
w := httptest.NewRecorder()
h.handleLidarrSearch(w, req)
return w
}
// TestHandleLidarrSearch_ValidationErrors covers bad kind and missing query
// in a table-driven test.
func TestHandleLidarrSearch_ValidationErrors(t *testing.T) {
h, _ := testHandlers(t)
cases := []struct {
name string
q string
kind string
wantStatus int
wantCode string
}{
{
name: "bad kind playlist",
q: "Beatles",
kind: "playlist",
wantStatus: http.StatusBadRequest,
wantCode: "bad_kind",
},
{
name: "missing query",
q: "",
kind: "artist",
wantStatus: http.StatusBadRequest,
wantCode: "missing_query",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
url := fmt.Sprintf("/api/lidarr/search?q=%s&kind=%s", tc.q, tc.kind)
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
h.handleLidarrSearch(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d; body = %s", w.Code, tc.wantStatus, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode body: %v; body=%s", err, w.Body.String())
}
if resp.Error.Code != tc.wantCode {
t.Errorf("error.code = %q, want %q", resp.Error.Code, tc.wantCode)
}
})
}
}
// TestHandleLidarrSearch_DisabledReturns503 verifies that a disabled Lidarr
// config causes the handler to return 503 lidarr_disabled.
func TestHandleLidarrSearch_DisabledReturns503(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
// Config is already disabled after resetLidarrState; ensure it's explicit.
saveLidarrConfig(t, h, "http://127.0.0.1:1", false)
req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil)
w := httptest.NewRecorder()
h.handleLidarrSearch(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode body: %v; body=%s", err, w.Body.String())
}
if resp.Error.Code != "lidarr_disabled" {
t.Errorf("error.code = %q, want lidarr_disabled", resp.Error.Code)
}
}
// TestHandleLidarrSearch_HappyPath_RequestableResult checks a result that is
// neither in the library nor already requested.
func TestHandleLidarrSearch_HappyPath_RequestableResult(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
const mbid = "artist-mbid-abc123"
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "The Beatles"))
saveLidarrConfig(t, h, stub.URL, true)
w := doLidarrSearch(t, h, "Beatles", "artist")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var results []lidarrSearchResult
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
r := results[0]
if r.MBID != mbid {
t.Errorf("mbid = %q, want %q", r.MBID, mbid)
}
if r.InLibrary {
t.Errorf("in_library = true, want false")
}
if r.Requested {
t.Errorf("requested = true, want false")
}
}
// TestHandleLidarrSearch_HappyPath_InLibraryResult checks that a result whose
// MBID matches an artist in the local DB has in_library=true.
func TestHandleLidarrSearch_HappyPath_InLibraryResult(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
const mbid = "artist-mbid-inlib"
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Library Artist"))
saveLidarrConfig(t, h, stub.URL, true)
// Seed the artist with the matching MBID so in_library becomes true.
seedArtistWithMBID(t, h, dbtest.TestUserPrefix+"LibraryArtist", mbid)
w := doLidarrSearch(t, h, "Library+Artist", "artist")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var results []lidarrSearchResult
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
if !results[0].InLibrary {
t.Errorf("in_library = false, want true")
}
}
// TestHandleLidarrSearch_HappyPath_AlreadyRequested verifies that a result
// whose MBID matches a pending lidarr_requests row has requested=true.
func TestHandleLidarrSearch_HappyPath_AlreadyRequested(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
const mbid = "artist-mbid-requested"
stub := newLidarrStub(t, lidarrArtistStubBody(mbid, "Requested Artist"))
saveLidarrConfig(t, h, stub.URL, true)
// Seed a user and a pending request for the artist MBID.
user := seedUser(t, h.pool, "requester", "pw", false)
seedRequestForMBID(t, h, user.ID, dbq.LidarrRequestKindArtist, mbid, mbid)
w := doLidarrSearch(t, h, "Requested+Artist", "artist")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var results []lidarrSearchResult
if err := json.Unmarshal(w.Body.Bytes(), &results); err != nil {
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
if !results[0].Requested {
t.Errorf("requested = false, want true")
}
}
// TestHandleLidarrSearch_LidarrUnreachableReturns503 verifies that a
// connection-refused Lidarr base URL returns 503 lidarr_unreachable.
func TestHandleLidarrSearch_LidarrUnreachableReturns503(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
// Port 1 is reserved and will refuse connections immediately.
saveLidarrConfig(t, h, "http://127.0.0.1:1", true)
req := httptest.NewRequest(http.MethodGet, "/api/lidarr/search?q=Beatles&kind=artist", nil)
// Use a short-deadline context so the test doesn't hang waiting for TCP.
ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second)
defer cancel()
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.handleLidarrSearch(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body=%s", err, w.Body.String())
}
if resp.Error.Code != "lidarr_unreachable" {
t.Errorf("error.code = %q, want lidarr_unreachable", resp.Error.Code)
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ func seedPlaySession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, start
func quarantineTrack(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
t.Helper()
_, err := pool.Exec(context.Background(),
`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
`INSERT INTO quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
userID, trackID,
)
if err != nil {
+15 -16
View File
@@ -6,8 +6,9 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
)
// Helpers for the handlers that produce notifications (M489). Every call is
@@ -22,17 +23,17 @@ func userLabel(u dbq.User) string {
return u.Username
}
func requestPayload(row dbq.LidarrRequest) notifications.Payload {
func requestPayload(row dbq.Request) notifications.Payload {
return notifications.Payload{
RequestID: uuidToString(row.ID),
RequestKind: string(row.Kind),
Name: lidarrrequests.DisplayName(row),
Name: requests.DisplayName(row),
}
}
// notifyRequestDecided tells the requester an admin approved or declined
// their request. An admin deciding their own request needs no notice.
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.LidarrRequest, reason string) {
func (h *handlers) notifyRequestDecided(ctx context.Context, kind notifications.Kind, admin dbq.User, row dbq.Request, reason string) {
if row.UserID == admin.ID {
return
}
@@ -66,21 +67,19 @@ func (h *handlers) trackLabel(ctx context.Context, trackID pgtype.UUID) string {
if err != nil {
return ""
}
if a, aerr := q.GetArtistByID(ctx, t.ArtistID); aerr == nil && a.Name != "" {
return a.Name + " – " + t.Title
if a, aerr := q.GetArtistByID(ctx, t.ArtistID); aerr == nil {
return library.TrackLabel(a.Name, t.Title)
}
return t.Title
}
// notifyPlaybackErrors tells admins how many playback errors await review.
// The notice coalesces, so while it is unread a new report only updates the
// count.
func (h *handlers) notifyPlaybackErrors(ctx context.Context) {
n, err := dbq.New(h.pool).CountUnresolvedPlaybackErrors(ctx)
if err != nil {
h.logger.Warn("api: playback_error: count unresolved", "err", err)
return
// notifyPlaybackErrors tells admins a track failed to play, naming it and who
// it failed for (M542). The notice coalesces: while it is unread each report
// adds one to its count and its name to the front of the list.
func (h *handlers) notifyPlaybackErrors(ctx context.Context, trackID pgtype.UUID, user dbq.User) {
p := notifications.Payload{Count: 1}
if name := h.trackLabel(ctx, trackID); name != "" {
p.Items = []string{name + " (" + userLabel(user) + ")"}
}
h.notifier.NotifyLogged(ctx, notifications.KindPlaybackErrors, notifications.ToAdmins(pgtype.UUID{}),
notifications.Payload{Count: n}.Map())
h.notifier.NotifyLogged(ctx, notifications.KindPlaybackErrors, notifications.ToAdmins(pgtype.UUID{}), p.Map())
}
+8 -27
View File
@@ -53,27 +53,9 @@ func wantInbox(t *testing.T, who string, got []inboxItem, kinds ...notifications
}
}
// newApprovingLidarrStub answers every Lidarr call an approval makes.
func newApprovingLidarrStub(t *testing.T) *httptest.Server {
t.Helper()
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/metadataprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case strings.Contains(r.URL.Path, "/qualityprofile"):
_, _ = w.Write([]byte(`[{"id":1,"name":"Lossless"}]`))
default:
_, _ = w.Write([]byte(`{"id":1}`))
}
}))
t.Cleanup(stub.Close)
return stub
}
func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "np-alice", "pw", false)
admin := seedUser(t, pool, "np-admin", "pw", true)
@@ -99,9 +81,8 @@ func TestNotify_NewRequestReachesOtherAdminsOnce(t *testing.T) {
}
func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "np-auto", "pw", false)
admin := seedUser(t, h.pool, "np-auto-admin", "pw", true)
@@ -123,9 +104,8 @@ func TestNotify_AutoApprovedRequestWaitsOnNobody(t *testing.T) {
}
func TestNotify_DecisionsReachTheRequester(t *testing.T) {
h, _ := testHandlersWithClientFn(t)
resetLidarrState(t, h)
saveLidarrConfig(t, h, newApprovingLidarrStub(t).URL, true)
h, _ := testHandlers(t)
resetRequests(t, h)
alice := seedUser(t, h.pool, "np-dec-alice", "pw", false)
admin := seedUser(t, h.pool, "np-dec-admin", "pw", true)
@@ -231,8 +211,9 @@ func TestNotify_PlaybackErrorsCoalesceIntoOneCount(t *testing.T) {
got := inboxOf(t, pool, admin)
wantInbox(t, "admin", got, notifications.KindPlaybackErrors)
if got[0].Payload.Count != 2 {
t.Errorf("count = %d, want 2 unresolved", got[0].Payload.Count)
if got[0].Payload.Count != 2 || len(got[0].Payload.Items) != 2 ||
!strings.HasSuffix(got[0].Payload.Items[0], "np-play-alice)") {
t.Errorf("payload = %+v, want two reports, each naming the track and alice", got[0].Payload)
}
wantInbox(t, "reporter", inboxOf(t, pool, alice))
}
+1 -1
View File
@@ -130,7 +130,7 @@ func (h *handlers) handleReportPlaybackError(w http.ResponseWriter, r *http.Requ
writeErr(w, apierror.Internal(err))
return
}
h.notifyPlaybackErrors(r.Context())
h.notifyPlaybackErrors(r.Context(), trackID, user)
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
}
+12 -12
View File
@@ -8,8 +8,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
)
// quarantineView is the JSON shape returned by the user-facing endpoints
@@ -22,7 +22,7 @@ type quarantineView struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView {
func quarantineViewFrom(row dbq.Quarantine) quarantineView {
return quarantineView{
UserID: uuidToString(row.UserID),
TrackID: uuidToString(row.TrackID),
@@ -55,12 +55,12 @@ func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
writeErr(w, apierror.BadRequest("bad_request", "invalid track id"))
return
}
row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
row, err := h.quarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrBadReason):
case errors.Is(err, quarantine.ErrBadReason):
writeErr(w, apierror.BadRequest("bad_reason", "invalid reason"))
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
case errors.Is(err, quarantine.ErrTrackNotFound):
writeErr(w, &apierror.Error{Status: 404, Code: "track_not_found", Message: "track does not exist"})
default:
h.logger.Error("api: flag", "err", err)
@@ -88,8 +88,8 @@ func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil {
if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) {
if err := h.quarantine.Unflag(r.Context(), user.ID, id); err != nil {
if errors.Is(err, quarantine.ErrQuarantineNotFound) {
writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"})
return
}
@@ -125,7 +125,7 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
if !ok {
return
}
rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID)
rows, err := h.quarantine.ListMine(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: list mine", "err", err)
writeErr(w, apierror.InternalMsg("list failed", err))
@@ -134,10 +134,10 @@ func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request
out := make([]quarantineMineView, 0, len(rows))
for _, row := range rows {
out = append(out, quarantineMineView{
TrackID: uuidToString(row.LidarrQuarantine.TrackID),
Reason: string(row.LidarrQuarantine.Reason),
Notes: row.LidarrQuarantine.Notes,
CreatedAt: row.LidarrQuarantine.CreatedAt,
TrackID: uuidToString(row.Quarantine.TrackID),
Reason: string(row.Quarantine.Reason),
Notes: row.Quarantine.Notes,
CreatedAt: row.Quarantine.CreatedAt,
TrackTitle: row.TrackTitle,
TrackDurationMs: row.TrackDurationMs,
AlbumID: uuidToString(row.AlbumID),
+35 -46
View File
@@ -9,8 +9,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
)
// requestView is the JSON shape returned by all /api/requests handlers.
@@ -20,14 +20,12 @@ type requestView struct {
UserID pgtype.UUID `json:"user_id"`
Status string `json:"status"`
Kind string `json:"kind"`
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
LidarrTrackMBID *string `json:"lidarr_track_mbid,omitempty"`
ArtistMBID string `json:"artist_mbid"`
AlbumMBID *string `json:"album_mbid,omitempty"`
TrackMBID *string `json:"track_mbid,omitempty"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
TrackTitle *string `json:"track_title,omitempty"`
QualityProfileID *int32 `json:"quality_profile_id,omitempty"`
RootFolderPath *string `json:"root_folder_path,omitempty"`
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
Notes *string `json:"notes,omitempty"`
@@ -45,20 +43,18 @@ type requestView struct {
ImportedTrackCount int `json:"imported_track_count"`
}
func requestViewFrom(row dbq.LidarrRequest) requestView {
func requestViewFrom(row dbq.Request) requestView {
return requestView{
ID: row.ID,
UserID: row.UserID,
Status: string(row.Status),
Kind: string(row.Kind),
LidarrArtistMBID: row.LidarrArtistMbid,
LidarrAlbumMBID: row.LidarrAlbumMbid,
LidarrTrackMBID: row.LidarrTrackMbid,
ArtistMBID: row.ArtistMbid,
AlbumMBID: row.AlbumMbid,
TrackMBID: row.TrackMbid,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
TrackTitle: row.TrackTitle,
QualityProfileID: row.QualityProfileID,
RootFolderPath: row.RootFolderPath,
DecidedAt: row.DecidedAt,
DecidedBy: row.DecidedBy,
Notes: row.Notes,
@@ -81,9 +77,9 @@ func requestViewFrom(row dbq.LidarrRequest) requestView {
// N+1 against the request list: acceptable at admin scale (dozens of
// in-flight requests at most). Errors are swallowed — progress is a
// reporting nicety, not a correctness gate.
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, view *requestView) {
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.Request, view *requestView) {
switch row.Kind {
case dbq.LidarrRequestKindArtist:
case dbq.RequestKindArtist:
if !row.MatchedArtistID.Valid {
return
}
@@ -93,7 +89,7 @@ func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, vi
if n, err := q.CountTracksByArtist(ctx, row.MatchedArtistID); err == nil {
view.ImportedTrackCount = int(n)
}
case dbq.LidarrRequestKindAlbum:
case dbq.RequestKindAlbum:
if !row.MatchedAlbumID.Valid {
return
}
@@ -101,19 +97,20 @@ func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, vi
if n, err := q.CountTracksByAlbum(ctx, row.MatchedAlbumID); err == nil {
view.ImportedTrackCount = int(n)
}
case dbq.LidarrRequestKindTrack:
case dbq.RequestKindTrack:
if row.MatchedTrackID.Valid {
view.ImportedTrackCount = 1
}
}
}
// createRequestBody is the decoded JSON for POST /api/requests.
// createRequestBody is the decoded JSON for POST /api/requests. album_mbid
// is the album's MusicBrainz release group, as catalogue search returns it.
type createRequestBody struct {
Kind string `json:"kind"`
LidarrArtistMBID string `json:"lidarr_artist_mbid"`
LidarrAlbumMBID string `json:"lidarr_album_mbid"`
LidarrTrackMBID string `json:"lidarr_track_mbid"`
ArtistMBID string `json:"artist_mbid"`
AlbumMBID string `json:"album_mbid"`
TrackMBID string `json:"track_mbid"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
TrackTitle string `json:"track_title"`
@@ -131,17 +128,17 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
return
}
row, created, err := h.lidarrRequests.CreateTracked(r.Context(), user.ID, lidarrrequests.CreateParams{
row, created, err := h.requests.CreateTracked(r.Context(), user.ID, requests.CreateParams{
Kind: body.Kind,
LidarrArtistMBID: body.LidarrArtistMBID,
LidarrAlbumMBID: body.LidarrAlbumMBID,
LidarrTrackMBID: body.LidarrTrackMBID,
ArtistMBID: body.ArtistMBID,
AlbumMBID: body.AlbumMBID,
TrackMBID: body.TrackMBID,
ArtistName: body.ArtistName,
AlbumTitle: body.AlbumTitle,
TrackTitle: body.TrackTitle,
})
if err != nil {
if errors.Is(err, lidarrrequests.ErrInvalidKindFields) {
if errors.Is(err, requests.ErrInvalidKindFields) {
writeErr(w, apierror.BadRequest("mbid_required", err.Error()))
return
}
@@ -150,22 +147,14 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
return
}
// Auto-approve flow (#355 / #376 U2.5): when the user has the
// auto_approve_requests flag set by an admin, transition the
// just-created pending row through Approve so it dispatches to
// Lidarr immediately. Failures here (Lidarr disabled, defaults
// missing, network error) leave the row pending — same fallback
// as a manual admin approve that hits the same error path. The
// user gets a 201 with whatever status Approve produced; admin
// can still resolve the row in /admin/requests if it stayed
// pending.
//
// The "actor" id passed to Approve is the user themselves — the
// auto-approval is the user acting under the privilege the admin
// granted them via the toggle. The trail of "admin set the flag"
// already lives in audit_log (ActionAutoApproveToggle from U2).
// Auto-approve flow (#355 / #376 U2.5): when an admin has set the user's
// auto_approve_requests flag, the new request goes straight onto the
// wanted list as if approved. A failure leaves it pending for an admin,
// the same as a manual approval hitting the same error. The approver is
// the user themselves, acting under the privilege the admin granted; the
// grant is in audit_log (ActionAutoApproveToggle).
if user.AutoApproveRequests {
approved, aerr := h.lidarrRequests.Approve(r.Context(), row.ID, user.ID, lidarrrequests.ApproveOverrides{})
approved, aerr := h.requests.Approve(r.Context(), row.ID, user.ID, pgtype.UUID{})
if aerr == nil {
row = approved
} else {
@@ -180,7 +169,7 @@ func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
// (M489). One that deduped into a request already in flight was
// announced when it was first made, and the requester, if they are an
// admin themselves, needs no notice of their own request.
if created && row.Status == dbq.LidarrRequestStatusPending {
if created && row.Status == dbq.RequestStatusPending {
p := requestPayload(row)
p.Actor = userLabel(user)
h.notifier.NotifyLogged(r.Context(), notifications.KindRequestPending, notifications.ToAdmins(user.ID), p.Map())
@@ -197,7 +186,7 @@ func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) {
return
}
rows, err := h.lidarrRequests.ListForUser(r.Context(), user.ID, 100)
rows, err := h.requests.ListForUser(r.Context(), user.ID, 100)
if err != nil {
h.logger.Error("api: list requests", "err", err)
writeErr(w, apierror.InternalMsg("list failed", err))
@@ -222,7 +211,7 @@ func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) {
return
}
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetLidarrRequestByID, "request")
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetRequestByID, "request")
if apiErr != nil {
writeErr(w, apiErr)
return
@@ -252,12 +241,12 @@ func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
return
}
row, err := h.lidarrRequests.Cancel(r.Context(), id, user.ID)
row, err := h.requests.Cancel(r.Context(), id, user.ID)
if err != nil {
switch {
case errors.Is(err, lidarrrequests.ErrNotPending):
case errors.Is(err, requests.ErrNotPending):
writeErr(w, apierror.Conflict("request_not_pending", "request is not pending"))
case errors.Is(err, lidarrrequests.ErrNotFound):
case errors.Is(err, requests.ErrNotFound):
writeErr(w, apierror.NotFound("request"))
default:
h.logger.Error("api: cancel request", "err", err)
+29 -33
View File
@@ -73,7 +73,7 @@ func doCancelRequest(h *handlers, user dbq.User, id pgtype.UUID) *httptest.Respo
// via the handler and returns the parsed response.
func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, artistName string) requestView {
t.Helper()
body := fmt.Sprintf(`{"kind":"artist","lidarr_artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
body := fmt.Sprintf(`{"kind":"artist","artist_mbid":%q,"artist_name":%q}`, artistMBID, artistName)
w := doCreateRequest(h, user, body)
if w.Code != http.StatusCreated {
t.Fatalf("createArtistRequest: status = %d, want 201; body = %s", w.Code, w.Body.String())
@@ -85,15 +85,12 @@ func createArtistRequest(t *testing.T, h *handlers, user dbq.User, artistMBID, a
return rv
}
// TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending verifies
// that a user with auto_approve_requests=true still receives a 201 Created
// when Lidarr is disabled in the test environment — the auto-approve attempt
// fails inside Service.Approve with ErrLidarrDisabled, which the handler
// catches and logs, leaving the row in pending state. This is the U2.5
// fallback contract: a misconfigured Lidarr must not break request creation.
func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.T) {
// TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList verifies
// that a user with auto_approve_requests=true gets their request approved on
// creation (#355 / #376 U2.5): an artist request follows the artist at once.
func TestHandleCreateRequest_AutoApprove_GoesStraightOnTheWantedList(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
if _, err := pool.Exec(context.Background(),
@@ -105,13 +102,9 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
if err != nil {
t.Fatalf("reload user: %v", err)
}
if !refreshed.AutoApproveRequests {
t.Fatalf("auto_approve flag did not persist — bailing")
}
body := `{"kind":"artist","lidarr_artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
body := `{"kind":"artist","artist_mbid":"auto-mbid-1","artist_name":"Autoband"}`
w := doCreateRequest(h, refreshed, body)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
}
@@ -119,8 +112,11 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
if err := json.Unmarshal(w.Body.Bytes(), &rv); err != nil {
t.Fatalf("decode: %v", err)
}
if rv.Status != "pending" {
t.Errorf("status = %q, want pending (auto-approve falls back when Lidarr disabled)", rv.Status)
if rv.Status != "approved" {
t.Errorf("status = %q, want approved", rv.Status)
}
if _, err := dbq.New(pool).GetFollow(context.Background(), "auto-mbid-1"); err != nil {
t.Errorf("the artist should be followed: %v", err)
}
}
@@ -128,10 +124,10 @@ func TestHandleCreateRequest_AutoApprove_LidarrDisabled_StaysPending(t *testing.
// returns 201 with kind=artist and status=pending.
func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
body := `{"kind":"artist","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
body := `{"kind":"artist","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusCreated {
@@ -147,8 +143,8 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
if rv.Status != "pending" {
t.Errorf("status = %q, want pending", rv.Status)
}
if rv.LidarrArtistMBID != "artist-mbid-1" {
t.Errorf("lidarr_artist_mbid = %q", rv.LidarrArtistMBID)
if rv.ArtistMBID != "artist-mbid-1" {
t.Errorf("artist_mbid = %q", rv.ArtistMBID)
}
if rv.ArtistName != "The Beatles" {
t.Errorf("artist_name = %q", rv.ArtistName)
@@ -159,14 +155,14 @@ func TestHandleCreateRequest_Artist_HappyPath(t *testing.T) {
}
// TestHandleCreateRequest_AlbumWithoutAlbumMBID_400 verifies that sending
// kind=album without lidarr_album_mbid returns 400 with mbid_required.
// kind=album without album_mbid returns 400 with mbid_required.
func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Missing lidarr_album_mbid and album_title.
body := `{"kind":"album","lidarr_artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
// Missing album_mbid and album_title.
body := `{"kind":"album","artist_mbid":"artist-mbid-1","artist_name":"The Beatles"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusBadRequest {
@@ -189,11 +185,11 @@ func TestHandleCreateRequest_AlbumWithoutAlbumMBID_400(t *testing.T) {
// with missing track_mbid/track_title returns 400 with mbid_required.
func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
// Provides album fields but misses track_mbid and track_title.
body := `{"kind":"track","lidarr_artist_mbid":"artist-mbid-1","artist_name":"Beatles","lidarr_album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
body := `{"kind":"track","artist_mbid":"artist-mbid-1","artist_name":"Beatles","album_mbid":"album-mbid-1","album_title":"Abbey Road"}`
w := doCreateRequest(h, alice, body)
if w.Code != http.StatusBadRequest {
@@ -216,7 +212,7 @@ func TestHandleCreateRequest_TrackWithoutTrackFields_400(t *testing.T) {
// requests, not bob's.
func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -246,7 +242,7 @@ func TestHandleListRequests_OnlyOwnRequests(t *testing.T) {
// TestHandleGetRequest_OwnReturns200 verifies a caller can fetch their own request.
func TestHandleGetRequest_OwnReturns200(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "artist-mbid-get", "Get Artist")
@@ -268,7 +264,7 @@ func TestHandleGetRequest_OwnReturns200(t *testing.T) {
// cannot see another user's request.
func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
bob := seedUser(t, pool, "bob", "pw", false)
charlie := seedUser(t, pool, "charlie", "pw", false)
@@ -297,7 +293,7 @@ func TestHandleGetRequest_OtherUserNotAdmin_404(t *testing.T) {
// user's request.
func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
bob := seedUser(t, pool, "bob", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -322,7 +318,7 @@ func TestHandleGetRequest_OtherUserAsAdmin_200(t *testing.T) {
// request returns 200 with status=rejected.
func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-mbid-1", "Cancel Me")
@@ -344,7 +340,7 @@ func TestHandleCancelRequest_PendingHappyPath(t *testing.T) {
// rejected request returns 409 with request_not_pending.
func TestHandleCancelRequest_NonPending_409(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
rv := createArtistRequest(t, h, alice, "cancel-mbid-2", "Cancel Twice")
@@ -380,7 +376,7 @@ func TestHandleCancelRequest_NonPending_409(t *testing.T) {
// another user's request returns 409 (not_pending from the ownership miss).
func TestHandleCancelRequest_WrongUser_409(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice-cancel-owner", "pw", false)
bob := seedUser(t, pool, "bob-cancel-other", "pw", false)
+14
View File
@@ -0,0 +1,14 @@
package api
import (
"context"
"testing"
)
// resetRequests clears requests rows between tests.
func resetRequests(t *testing.T, h *handlers) {
t.Helper()
if _, err := h.pool.Exec(context.Background(), "DELETE FROM requests"); err != nil {
t.Fatalf("resetRequests: %v", err)
}
}

Some files were not shown because too many files have changed in this diff Show More