Commit Graph
625 Commits
Author SHA1 Message Date
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 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 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 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 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 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
bvandeusenandClaude Opus 5.5 d59b7fdc2a fix(test): the mix-writer test reaches oneCopyPerSong through export_test; suspect fixtures carry the verdict (M498 #5439)
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 4m36s
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 27s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:17:17 -04:00
bvandeusenandClaude Opus 5.5 53eb954c86 feat: video rips and stray copies handle themselves (M498 #5439)
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 25s
release / go (push) Failing after 58s
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 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
- The resolver also merges cross-release and mismatch groups where Lidarr
  maps exactly one copy: the others fulfil nothing, so removing them opens no
  hole (D-a rule 1). That covers a rip beside the clean copy on another
  release and a wrong-file import Lidarr holds unmapped. With two or more
  mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
  and the system mixes (tracks.source_verdict, migration 0077). It still plays
  when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
  and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.

Replacing a rip that has no clean copy is left for the operator to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:13:37 -04:00
bvandeusenandClaude Opus 5.5 5b372f61d7 feat: the same song on several releases counts as one song (M498 #5438)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m7s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m11s
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 / android (push) Canceled after 5m58s
release / Build signed APK (releases and dev) (push) Canceled after 5m59s
A single and the album it is on stay two files, since each fulfils its own
release in Lidarr, but they are one song to the listener.

- tracks.song_id links copies; the generated song_key (song_id, else the
  track's own id) is what they share (migration 0076).
- The resolver links each cross-release group every pass (idempotent; only
  with auto-resolve on) and, when a link is new, shares existing likes across
  the song and logs them for sync.
- A like or unlike (web and Subsonic) reaches every copy; each change is
  logged and published so clients update every heart.
- The Liked list and its count show the song once; Shuffle and the mix writer
  take one copy per song.
- A merge keeps the removed copy's song link; "Not the same song" on the
  Across releases tab dismisses the group and undoes the link.

Shared plays ("heard via another copy") are left for a later step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:07:55 -04:00
bvandeusenandClaude Opus 5.5 4ecff52f19 feat: duplicates resolve themselves where Lidarr says it is safe (M498)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the
operator. Most are safe to settle, and Lidarr defines what safe means: it
maps one file to each track of the release it monitors and downloads any
mapped file that disappears. Deleting a mapped copy opens exactly the hole
the operator saw Lidarr fill.

Classify (#5435)
- Migration 0075: duplicate_groups.class (same_release, cross_release,
  mismatch, review), resolve_note, resolved_automatically;
  duplicate_group_members.lidarr_state (tracked, unmapped);
  fingerprint_settings.auto_resolve; notification kind
  duplicates_resolved with both kind CHECKs swapped (rule 36).
- library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring
  credits, remaster notes and video-rip markers, and keeping live, demo,
  remix and instrumental. The rip markers move from api to library.

Choose the copy to keep (#5436)
- ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a
  clash-free track number, no rip marker in the name, an MBID), then the
  quality rules. File size picked the wrong Humanz copy in 6 of 21 groups.

Act (#5437)
- An hourly resolver pass reads Lidarr's unmapped files, matched by the
  last three path components, and records each copy's state.
- Same album, with at most one copy mapped: merged into the mapped copy.
  The merge is guarded, so a mapped copy can never be removed
  (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr).
- Same album, every copy mapped: the monitored release lists the song
  twice (Humanz's 14x12" box set). The pass moves Lidarr to the release
  that lists each song once and best covers what is on disk. It never
  picks one covering less, and is capped at 10 albums per pass.
  - Fixed point (lesson #4183): the chosen release no longer repeats.
  - The album is left alone for 24h while Lidarr rescans, so "every copy
    unmapped" mid-rescan is never read as licence to merge.
- Both actions are audited with no actor and summarised to admins. The
  operator can switch them off in the Fingerprinting card (rule 25).
- Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503
  lidarr_unavailable when Lidarr cannot say.

Web
- Duplicates gets tabs: Needs review, Across releases, Resolved
  automatically. Each loads as you scroll (rule 172), replacing the
  pager.
- Each copy says whether Lidarr uses it.
- The resolver's note shows on each group.
- The merge confirm blocks, before sending, a merge that would remove
  the copy Lidarr uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:49:37 -04:00
bvandeusenandClaude Opus 5.5 2205b2927b test: fix the Suspect sources tests CI rejected (#5410)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m36s
release / android (push) Successful in 5m4s
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) Successful in 1m11s
release / Verify release artifacts (tag releases only) (push) Skipped
- staticcheck QF1001: the nil-vs-empty check in TestSourceMarkersFor is
  now an early continue instead of a negated conjunction.
- The page test mocks #lib/api/admin.js whole, so suspectSourcesNextOffset
  was undefined there. Its test moves to admin.suspect-sources.test.ts,
  beside admin.scan.test.ts, against the real module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:56:18 -04:00
bvandeusenandClaude Opus 5.5 3f0540cb7a feat: admin Suspect sources report, for files named like video rips (#5410)
release / govulncheck (push) Successful in 37s
release / go (push) Failing after 1m10s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / web (push) Failing after 1m24s
release / integration (push) Successful in 4m38s
release / android (push) Successful in 5m5s
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 5m18s
Humanz turned out to be YouTube rips: "(Official Video)", "Visualizer", a
reaction video filed as a song, junk disc numbers (#5401). The library holds
about 200 more files named the same way. This report lists them, grouped by
folder like Missing files, with the markers each file name carries.

- GET /api/admin/library/suspect-sources: one marker list in Go builds both
  the Postgres ~* filter and each row's labels, so they cannot drift.
  Basename only; missing files are left out.
- Markers calibrated on the live library: "live in/at" dropped (real live
  albums), "reaction" narrowed (it caught "Chain Reaction").
- Admin tab "Suspect sources", read-only, loads as you scroll; a folder split
  across pages is joined back into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:51:01 -04:00
bvandeusenandClaude Opus 5.5 9be5bbae3c fix(discover): cap the taste-matched arm per album and artist before its LIMIT (#5356)
release / web (push) Successful in 1m37s
release / govulncheck (push) Successful in 53s
release / go (push) Successful in 2m7s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 6m34s
release / Build signed APK (releases and dev) (push) Successful in 6m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m36s
release / Verify release artifacts (tag releases only) (push) Skipped
Summed tag weight rewards a track for carrying many of the user's tags, so
on the deploy two artists whose every track carries the whole lo-fi profile
took all 120 rows of the taste-unheard query. capByAlbumAndArtist ran after
the LIMIT and left 6, and the arm with the lowest skip rate (12% against
~23%) handed its slots to dormant and random.

The query now ranks within album, then within artist over what the album
cap kept, before the LIMIT: the same walk the Go cap makes, so the bucket
fills from as many artists as match. The caps come from the Go constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:24:20 -04:00
bvandeusenandClaude Opus 5.5 63709a433d feat(notifications): grouped email digest, new music as a daily summary (#5346)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
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 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Nothing is emailed per event. New music (request_completed) goes out at
most once a day, at the summary hour in each user's own timezone, grouped
by artist. Everything else is batched: one email a window after the first
un-emailed item, holding whatever accumulated.

- Migration 0074: notification_email_settings (summary hour, batch window,
  admin-configurable) and user_notification_email_state (batch start, last
  sent, failures and retry_after per user and group). Existing rows are
  stamped emailed so the upgrade sends no backlog.
- The Notifier stamps emailed_at at write time when the recipient's email
  channel is off, so turning email on later doesn't send old items.
- Read rows are never selected. A row is stamped only after the mailer
  accepts, in one transaction with the state, against the read's clock, so
  a coalesced row updated mid-send stays pending.
- A failed send backs off 5m doubling to 6h; SMTP not configured just waits.
- Links come from the public address; without one the email has none.
- The mailer now RFC 2047-encodes subjects and strips line breaks from them.
- Admin → Integrations gains a Notification emails card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:48:22 -04:00
bvandeusenandClaude Opus 5.5 f11dba2336 feat(api): read-all takes an optional up_to cutoff (M489)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m40s
release / android (push) Successful in 5m1s
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) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android
queues "mark all read" for replay when offline, and a replay landing later
must not mark notices that arrived in between, which the user never saw.
A coalesced notice updated since then has a newer created_at, so it stays
unread. An empty body still marks everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:27:29 -04:00
bvandeusenandClaude Opus 5.5 1e9408c835 feat(notifications): library health reaches admins, coalesced (#5341)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 4m56s
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 15s
release / Verify release artifacts (tag releases only) (push) Skipped
- A failed scan run sends scan_failed. Each failure adds to the count and
  the notice shows the latest error. A scan cut short by shutdown says
  nothing.
- Marking tracks missing sends tracks_missing with a running count.
- A duplicate sweep that proposes a group it had not proposed before
  sends duplicates_found, counting everything awaiting review. A sweep
  that only re-finds known groups stays quiet, so a read notice isn't
  repeated every sweep (CountDuplicateGroupsDetectedSince).
- A playback-error report sends playback_errors, counting the unresolved
  errors (CountUnresolvedPlaybackErrors).

The library package gets its notifier as a package-level SetNotifier
beside SetEventBus, for the same reason the bus is package-level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:20:33 -04:00
bvandeusenandClaude Opus 5.5 069baeb14d feat(notifications): requests and flags reach the people who act on them (#5340)
- A new request still pending after any auto-approval notifies the
  admins (request_pending), but not the requester if they are an admin.
  A request that dedups into one already in flight is not announced again.
- Approving or rejecting a request notifies the requester, and a
  rejection carries the admin's notes as the reason. An admin deciding
  their own request gets nothing.
- The reconciler notifies the requester when their request arrives
  (request_completed), linking the matched album or artist.
- A request the re-acquisition sweeper files and cannot approve itself
  notifies the admins.
- A quarantine flag notifies every admin except the flagger, naming the
  track, the flagger and the reason.

lidarrrequests.Service.CreateTracked reports whether a request was
inserted or deduped; Create wraps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:17:11 -04:00
bvandeusenandClaude Opus 5.5 94ef8c1887 feat(api): notifications inbox and per-user settings endpoints (#5339)
release / go (push) Successful in 1m35s
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m18s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 4m58s
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 14s
release / Verify release artifacts (tag releases only) (push) Skipped
M489 step 2.

- GET /api/me/notifications?limit&before: newest first, keyset-paged on
  (created_at, id) with an opaque cursor, plus the unread count.
- GET /api/me/notifications/unread-count: the badge's cheap call.
- POST /api/me/notifications/{id}/read and /read-all. Mark-read is
  idempotent; another user's id is a 404, the same as a malformed one.
- GET/PUT /api/me/notification-settings: every kind the caller can receive
  (admin kinds only for admins) with inbox/phone/email. PUT is partial, so an
  offline replay sends only what was touched, and a batch with any invalid
  change applies nothing. The response says whether email can be delivered
  at all: no address on file, or SMTP not configured. A failed SMTP config
  read is a 500, not "not configured".

notifications.Render turns kind + payload into title, body and link on the
server, so the web inbox, the Android inbox, the phone's shade and the email
digest all say the same thing. mailer.Configured lifts Send's readiness
check out so settings can report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:10:05 -04:00
bvandeusenandClaude Opus 5.5 8bf333e748 feat(notifications): the inbox store, one writer, coalescing and retention (#5338)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m34s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 4m59s
release / android (push) Successful in 5m24s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
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
M489 step 1. The event bus is fire-and-forget, so a client that isn't
connected never hears that a request completed or that tracks went missing.
user_notifications is the durable record; the bus only nudges.

- Migration 0073: user_notifications (kind CHECK-gated, payload jsonb,
  read_at, coalesce_key, emailed_at) and user_notification_prefs (per user,
  per kind: inbox, phone, email). A missing pref row means the kind's
  defaults, so nothing is seeded.
- internal/notifications.Notifier is the only writer. It resolves recipients
  (admin kinds reach admins only, and never the excepted user), honours the
  inbox pref (phone and email ride on it), writes, and publishes a
  contentless notification.created nudge per recipient.
- Burst-prone admin kinds coalesce into one unread row: tracks_missing and
  scan_failed add up their counts, duplicates_found and playback_errors take
  the latest total. Once read, the next event is a new row.
- Retention: read rows go after 90 days, anything after a year, on the
  library_changes compactor's daily shape.

Tests: unit (channel rules, kind table) and integration (recipients, nudge,
coalescing both ways, prefs, owner-scoped idempotent mark-read, every kind
against both schema CHECKs, retention cut-offs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:06:39 -04:00
bvandeusenandClaude Opus 5.5 37d3a5bcd3 fix(library): read every genre of FLAC, Ogg, Opus and MP4 files (#2500)
release / govulncheck (push) Successful in 50s
release / web (push) Successful in 2m3s
release / go (push) Successful in 2m18s
release / integration (push) Successful in 5m42s
release / android (push) Successful in 6m30s
release / Build signed APK (releases and dev) (push) Successful in 6m0s
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
Vorbis comments repeat a field to give it several values (GENRE=Boom Bap,
GENRE=Downtempo, ...). dhowden/tag keeps comments in a map keyed by field
name, so each repeat overwrote the previous one and only the last genre
was stored. MP4 has the same gap: several data atoms in one ©gen atom, or
repeated ©gen atoms, collapse to one value.

On the operator's library 3,658 of 4,092 FLACs declare more than one
genre. Kupla's Life Forms carries eight and was stored as "Instrumental
Hip Hop" alone, so browse and the taste profile never saw the other seven.

- vorbisgenre.go reads the comment block directly: FLAC's metadata block
  (including FLACs behind an ID3v2 tag), and the comment packet of Ogg
  Vorbis and Opus, reassembled across pages when cover art makes it span
  several.
- mp4genre.go walks moov > udta > meta > ilst and returns every ©gen text
  value. It handles ISO and QuickTime meta layouts and a moov placed after
  mdat. A file with only the numeric gnre atom still falls back to
  dhowden, which resolves it.
- extractGenres routes VORBIS and MP4 through them, as #2499 did for
  ID3v2.
- tagReadVersion 3 -> 4, so the next scan re-reads the tags of files
  already indexed. Unchanged files keep their duration and fingerprint,
  so the pass costs tag reads only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:40:56 -04:00
bvandeusenandClaude Opus 5.5 efa3bf54ce fix(playlists): a missing track never seeds For You or Songs-like (#2701)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m27s
release / android (push) Successful in 5m2s
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 1m15s
release / Verify release artifacts (tag releases only) (push) Skipped
PickTopPlayedTracksForUser's liked tier read general_likes without
joining tracks. With no plays to seed from, For You could pick a liked
track whose file is gone. The play tiers were already safe: their
play_events join filters missing_since.

The same gap was in PickTopPlayedTrackForArtistByUser's fallback, which
seeds Songs-like from the artist's newest album when there are no recent
plays. It could pick a missing track, and since #5296 a missing track is
never fetched for similarity, so that seed has no edges either. The
caller already skips an empty seed, so an artist whose tracks are all
missing gets no Songs-like mix instead of one aimed at nothing.

Integration tests cover both cases: a liked-but-missing track is not a
seed, and the Songs-like fallback moves to the next album once the
newest one's track goes missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:10:17 -04:00
bvandeusenandClaude Opus 5.5 e5dac9ddf0 fix(similarity): keep ListenBrainz's whole answer and resolve it locally (#5296)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 5m36s
release / android (push) Successful in 7m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build signed APK (releases and dev) (push) Successful in 8m29s
release / Build + push container image (push) Successful in 1m47s
release / Verify release artifacts (tag releases only) (push) Skipped
The worker kept only the similar recordings already in the library, at most
20 of ListenBrainz's 50, and judged freshness by the edges it had written.
Two failures followed, both measured on the operator's library (#3879):

- A seed whose answer matched nothing wrote nothing, so it was never fresh.
  With the queue ordered by id, 25 such seeds held its head and were
  re-asked every hour; 17 of 2,466 played seeds had any edges.
- A recording that reached the library after its seed was fetched (a
  Lidarr import, an MBID from the AcoustID lookup) was never linked until
  a refetch, which for the stuck seeds never came.

Now every answer is cached whole in listenbrainz_similar_recordings and
every answer, an empty one or a permanent 4xx included, is recorded in
track_similarity_fetches. The queue reads the fetch record: never-fetched
first, then the oldest, refreshed after 30 days. The listenbrainz edges are
derived in SQL from the cache, one present track per recording and no cap,
for the seed just fetched and for every seed once per tick, so new arrivals
link within the hour without asking ListenBrainz again.

Artists get the same queue fix via artist_similarity_fetches; their answer
was already kept in artist_similarity and artist_similarity_unmatched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:56:22 -04:00
bvandeusenandClaude Opus 5.5 aa390c711a fix(recommendation): artist-level arms take one track per related artist in turn (#5297)
The similar_artists and coplay_artists arms ordered by artist score, so the
closest related artist's catalogue filled the whole LIMIT. On the operator's
library the 30-row similar_artists arm held exactly one artist for all 17
seeds measured (#3879), though each seed had 7-33 similar artists in the
library. Both arms now rank tracks within each artist and take every
artist's first track, best artist first, before anyone's second.

Both also skip missing tracks: the outer select already dropped them, but
only after they had taken places in the arm's LIMIT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:56:21 -04:00
bvandeusenandClaude Opus 5.5 865a3176c9 feat(library): fold a missing track into its on-disk replacement (M485 #5286 #5287)
release / web (push) Successful in 2m21s
release / go (push) Successful in 2m34s
release / govulncheck (push) Successful in 40s
release / integration (push) Successful in 6m17s
release / android (push) Successful in 6m36s
release / Build signed APK (releases and dev) (push) Successful in 6m6s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m19s
release / Verify release artifacts (tag releases only) (push) Skipped
A track marked missing whose replacement is already on disk under another
row — on the operator's library 355 of 451 missing tracks, nearly all Lidarr
mp3 -> flac upgrades — is folded into the replacement: likes, plays, playlist
entries and tags move across and the missing row goes. Move adoption could
not catch these: the replacements were re-encodes (no shared audio hash) with
no recording MBID at import.

Pairs (ListMissingTrackPairs): the same recording MBID within the album
group, or the same album row and title ignoring case. Each side must have
exactly one candidate; conflicting MBIDs refuse a pair. No duration or track
position gate: on the 142 pairs known to be one recording, 18% differed by
over 2s and the poorly tagged set is where numbering is broken (spike #5274).

Each pair folds in its own transaction after locking both rows and checking
the pair still holds. Runs automatically (operator, 2026-10-07) after a full
scan, after a watcher batch that added or updated tracks, and after an
AcoustID pass that matched any track. The first scan after deploy repairs the
existing rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:23:32 -04:00
bvandeusenandClaude Opus 5.5 2df28345b4 refactor(library): the per-copy fold is a helper the duplicate merge calls (M485 #5285)
MergeDuplicateGroup's loop body — repoint and copy every FK from a removed
copy onto the survivor, inherit its MBID, delete its row, tidy an emptied
album — moves into foldTrackInto, so the missing-pair pass can fold a stale
missing row into its replacement with the same mechanics. The group lock,
file removal and group bookkeeping stay in the merge. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:23:32 -04:00
bvandeusenandClaude Opus 5.5 4e3ce4065c fix(lidarr): complete a request only when the album actually came back (#5263)
release / govulncheck (push) Failing after 33s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 4m37s
release / android (push) Successful in 5m29s
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) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Re-acquisition targets albums with ANY track missing, and completion only
asked for a track on disk, so the tracks that never left completed every
re-acquisition request the moment Lidarr accepted the add (52 on the deploy,
each ~150ms after its add).

An album or track request now completes when an album named by its release
or group has a track on disk AND either a track arrived after the request
(a new album, or Lidarr fetching another release into its own row) or no
track that was missing at the request is still missing. added_at is the
arrival clock; updated_at moves on every tag re-read.

Migration 0071 reopens completed album/track requests whose matched album
fails that test, as approved with the match cleared; the Lidarr add stays
confirmed, so nothing is re-sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:55:32 -04:00
bvandeusenandClaude Opus 5.5 e509d7d5a9 feat(lidarr): ask Lidarr for release groups; repair stored release-id requests (M483 #5244)
release / web (push) Successful in 1m41s
release / go (push) Successful in 2m33s
release / govulncheck (push) Successful in 22s
release / integration (push) Successful in 6m0s
release / android (push) Successful in 6m7s
release / Build signed APK (releases and dev) (push) Successful in 6m8s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m43s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr's metadata is keyed by MusicBrainz release group, but re-acquisition
requested albums by their release id, so every add came back "not found".

- Sweeper requests an album by its tag-supplied release group, else the one
  MusicBrainz names (cached onto the album). An album MusicBrainz cannot name
  is skipped and counted, with no attempt spent.
- Reconciler: an add refused as not found re-reads the request's album id as
  a release (library first, then MusicBrainz), rewrites the request to the
  group and adds again. This repairs the requests already stored.
- Completion matches an album by release id or release group, and only once a
  track of it is on disk, so a re-acquisition request no longer completes
  against the row of the album it is trying to bring back.

Closes #5241.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:38:45 -04:00
bvandeusenandClaude Opus 5.5 2b4e274b12 feat(tags): resolve a MusicBrainz release to its release group (M483 #5243)
ReleaseGroupForRelease asks /ws/2/release/<id>?inc=release-groups through
the registered MusicBrainz provider, so it shares that provider's client
and 1 req/s limiter with tag enrichment and respects its on/off switch.
ErrNotFound when switched off or MusicBrainz has no such release (an id
that is already a release group included); ErrTransient to retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:34:04 -04:00
bvandeusenandClaude Opus 5.5 5c19a916ba feat(library): store each album's MusicBrainz release-group id (M483 #5242)
release / govulncheck (push) Successful in 22s
release / web (push) Successful in 1m14s
release / go (push) Successful in 1m33s
release / integration (push) Successful in 4m36s
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 / android (push) Canceled after 5m24s
release / Build signed APK (releases and dev) (push) Canceled after 5m29s
albums.mbid is the release id (Picard's musicbrainz_albumid, one edition).
Lidarr names albums by release group, so re-acquisition and request
completion need that id too (#5241).

Migration 0070 adds albums.release_group_mbid (nullable, non-unique index:
several releases share a group). The scanner reads musicbrainz_releasegroupid
through extractReleaseGroupMBID, writes it on insert and heals it onto
existing rows when NULL. tagReadVersion goes to 3 so the next scan fills
it for the library already indexed, bound by tag reads (no ffprobe, no
decode).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:33:26 -04:00
bvandeusenandClaude Opus 5.5 bf6364b709 fix(lidarr): send the artist add's monitor choice in addOptions (#5239)
release / govulncheck (push) Successful in 29s
release / web (push) Successful in 1m58s
release / go (push) Successful in 2m14s
release / integration (push) Successful in 5m17s
release / android (push) Successful in 6m21s
release / Build signed APK (releases and dev) (push) Successful in 6m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m1s
release / Verify release artifacts (tag releases only) (push) Skipped
ArtistResource has no top-level `monitor`. Lidarr reads the choice from
AddOptions (AddArtistOptions, a MonitoringOptions), so the "all"/"future"
we sent there was dropped on deserialisation. AddOptions.Monitor stayed
Unknown, and AlbumMonitoredService.SetAlbumMonitoredStatus returns early
on Unknown. The request's monitoring was never applied.

Send monitor and monitored inside addOptions with searchForMissingAlbums,
the shape Lidarr's getNewArtist.js posts, and set monitorNewItems "all"
explicitly: both choices mean new releases are watched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:58:55 -04:00