Commit Graph
110 Commits
Author SHA1 Message Date
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 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 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 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 015fe1d3b2 fix(web): admin error lines use text-error-fg, not the untokened text-oxblood (#5417)
release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m42s
release / go (push) Successful in 1m52s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m48s
release / Build signed APK (releases and dev) (push) Successful in 5m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m28s
release / Verify release artifacts (tag releases only) (push) Skipped
text-oxblood is not a theme token, so Tailwind emits nothing and the scan
error on Admin → Overview and "It stopped early" on Duplicates rendered as
body text. Same fix as the album page in #5392; no uses remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:17:34 -04:00
bvandeusenandClaude Opus 5.5 0f9ddbca97 feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
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 21m41s
Preference 172: no "Load more" buttons. Eight remained, on Liked (three
sections), the three Search overflow pages, Genres and Years.

- ListContinuation: the one bottom-of-list block. It loads the next page
  ahead of the reader, announces "Loading more…" through aria-live, shows an
  optional end line, and on a failed page shows the error with Try again,
  dropping the sentinel so a dead endpoint isn't re-hit on every scroll.
  Used on all ten paged lists, including the four that already autoloaded.
- A failed next page no longer replaces the list. TanStack sets isError for
  it, so the page-level error branches now apply only to a failed first
  load. Genres and Years did the same with their own loader; a later
  page's failure now keeps the grid.
- Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a
  long Artists list loading as you scroll would bury the other two. It
  opens on the first tab that has likes.
- TabStrip: the in-page tab strip, now shared by Liked, Playback errors
  and Requests.
- test-utils/intersectionObserver: a stand-in so tests can scroll to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:55:33 -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 94a9c8cbe3 chore(deps): SvelteKit 3 with adapter-static 4 and TypeScript 6, full-tree npm audit (#5021)
Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps,
plus the migration they need. The mechanical part is `sv migrate
sveltekit-3`, run one task at a time and reviewed:

- svelte.config.js is gone. Its options move into sveltekit() in
  vite.config.ts, exported as kitOptions so vitest.config.ts runs the
  same kit setup, including the $test-utils alias the tests import.
- $lib becomes #lib through package.json "imports". There is no
  src/lib/index, so only the "#lib/*" entry is kept.
- tsconfig extends $app/tsconfig.
- Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite
  ^8.0.12, svelte-check ^4.7.5.

By hand, from the codemod's list of non-automated tasks:

- goto's replaceState option is now replace; keepFocus becomes
  reset: false. For the search typeahead, reset: false also stops the
  scroll-to-top, which is wanted while typing.
- The test setup mocks drop pushState/replaceState and $app/paths
  base/assets, which kit 3 removed, and mock refreshAll in place of
  invalidateAll.
- The other flagged files only read page.url or goto internal routes,
  so they needed no change.

TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares
typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to
7 once both accept it.

With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0,
so the web lane now audits every dependency rather than only what
ships to browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:50:39 -04:00
bvandeusenandClaude Opus 5.5 fecd030b68 chore(deps): Tailwind 4 (#5021)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m53s
release / integration (push) Successful in 5m37s
release / android (push) Successful in 6m54s
release / Build signed APK (releases and dev) (push) Successful in 7m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Failing after 34s
release / Verify release artifacts (tag releases only) (push) Skipped
Renovate's tailwindcss bump (PR #150) plus the migration it needs:

- Theme moves from tailwind.config.js into app.css as `@theme inline`,
  mapping the same FabledSword tokens. tailwind.config.js is gone.
- PostCSS runs @tailwindcss/postcss; autoprefixer is dropped, since
  Tailwind 4 prefixes through Lightning CSS.
- Class renames from @tailwindcss/upgrade 4.3.3, reviewed: outline-none
  -> outline-hidden, focus-visible:outline -> outline-solid, shadow ->
  shadow-sm, shadow-sm -> shadow-xs, flex-shrink-0 -> shrink-0. Bare
  `rounded` stays: v4 keeps it at 0.25rem, as before.
- Three v3 preflight defaults kept in a base layer so nothing changes on
  screen: gray-200 default border colour, gray-400 placeholder text and
  the pointer cursor on buttons.
- The unused class-based dark variant is not carried over; no template
  uses `dark:`.

Clears the five high and two moderate npm audit findings that came in
through Tailwind 3 (braces, chokidar, micromatch, fast-glob,
postcss-selector-parser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:46:07 -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 5dffe51b95 feat(web): duplicates report flags identical audio under different titles (#3885)
release / govulncheck (push) Successful in 15s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m10s
release / go (push) Successful in 1m28s
release / integration (push) Successful in 4m21s
release / android (push) Successful in 4m43s
release / Build signed APK (releases and dev) (push) Successful in 4m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
An exact-tier group whose copies carry different titles means at least one
file's tags are wrong, and the recording the other title names may be missing
from the library. WWW (2020) was this: "WWW" was a second copy of the
instrumental, the vocal was absent, and nothing said so. The report now names
the titles and says what it implies, so the absence surfaces at the moment of
choosing which copy to keep.

Titles compare case- and whitespace-insensitively. Acoustic-tier groups are
left alone: across encodings a "Remastered" suffix is routine, not a mislabel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:33:37 -04:00
bvandeusenandClaude Opus 5.5 b46c080d19 fix(web): all accent text and icons use accent-fg (#5318)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m26s
release / integration (push) Successful in 4m18s
release / android (push) Successful in 4m44s
release / Build signed APK (releases and dev) (push) Successful in 4m53s
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 raw accent fails AA as text on every dark surface, not only on its
own tint: 3.04:1 on the page, 2.70 on iron, 2.21 on slate, against 4.5.
accent-fg (the house formula, 45% toward parchment) measures 5.62 at
worst across both modes. The operator chose the readable colour over the
signature teal for text, on 2026-10-08.

- 36 sites swap. They are 35 Tailwind uses: links, "Now playing", the
  ingest progress line, active shuffle/repeat, the liked heart, the app
  download icon and its hover. The last is the alphabet rail's pending
  spinner in CSS. Icons follow the text: as graphics they need only
  3:1, and the raw accent misses even that on iron.
- check-tint-contrast adds accent to TEXT_NEVER_RAW, so a new raw
  text-accent or color: var(--fs-accent) fails the web lane. Run against
  the files before the swap, it finds all 36. Borders, rings and fills
  keep the raw accent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:17:19 -04:00
bvandeusenandClaude Opus 5.5 743b6f5eac fix(web): error text uses error-fg on every surface, not only on tints (#3150)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m30s
release / android (push) Successful in 5m18s
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 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Raw error red fails AA as text even with no tint behind it: in dark mode
it measures 3.63:1 on obsidian, 3.23 on iron and 2.64 on slate, against
4.5. error-fg (the house formula, 50% toward parchment) measures 5.30 at
worst across both modes.

- All 19 text-error uses become text-error-fg: the "Couldn't load"
  messages on the admin pages, the integrations form errors, the flag
  popover, and the error toast's text. The toast keeps its error border,
  since a border is a graphic with a 3:1 floor.
- check-tint-contrast flags raw error text anywhere (text-error,
  class:text-error, color: var(--fs-error)) and leaves borders and
  outlines alone. Run against the files before the swap, it finds all 19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:00:28 -04:00
bvandeusenandClaude Opus 5.5 fdee77eaec fix(web): text on a tint of its own hue uses the house -fg tokens (#3150)
release / govulncheck (push) Successful in 38s
release / integration (push) Successful in 5m1s
release / Build + push container image (push) Successful in 1m17s
release / Verify release artifacts (tag releases only) (push) Skipped
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m48s
release / android (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Attach APK to the Release (tag releases only) (push) Skipped
A hue painted as text on a color-mix tint of itself sits close to the
surface under it. On Minstrel's surfaces the raw accent on its 15% tint
measures 1.97:1 at worst (dark mode, hover surface), against AA's 4.5.

- tokens.json gains colors.fg: the five FabledSword -fg formulas (accent
  45%, success 45%, warning, error and info 50%), each mixed toward
  parchment so one declaration serves both modes. Success is Minstrel's
  moss. tokens-to-css emits them in :root.
- Tailwind exposes them as text-accent-fg, text-warning-fg, text-error-fg
  and text-info-fg.
- 23 sites swapped: 14 Tailwind class strings (PlayerBar and the admin
  count pills) and 9 CSS rules (StatusPill's four tones and five accent
  chips). Worst case after: accent-fg 5.03, error-fg 4.75, warning-fg
  4.92, success-fg 4.85.
- scripts/check-tint-contrast.js finds the pair in either spelling. Its
  test scans src in the web Vitest lane and fails on any new site, with
  fixture cases showing it can fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:51:52 -04:00
bvandeusenandClaude Opus 5.5 e8eee55325 fix(web): AcoustID card's loading line names itself (M401 #3922)
release / integration (push) Successful in 6m4s
release / android (push) Successful in 8m1s
release / Build signed APK (releases and dev) (push) Successful in 8m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 28s
release / go (push) Successful in 1m41s
release / web (push) Successful in 1m21s
Its bare "Loading…" made the Integrations page's cover-providers test find
two matches for /loading…/i (Vitest, run 8487). "Loading AcoustID
settings…" also says which card is loading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:47:14 -04:00
bvandeusenandClaude Opus 5.5 0a7f788390 feat(web): AcoustID card on Integrations — key, threshold, coverage by source (M401 #3922)
release / go (push) Successful in 2m25s
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 21s
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 3m34s
release / android (push) Canceled after 1m3s
release / Build signed APK (releases and dev) (push) Canceled after 1m3s
The card takes the slot of the unimplemented "MusicBrainz overrides"
placeholder. Rows:
- the on switch
- a write-only key field (the stored key is never sent back), with a
  link to register an application
- the minimum score (0.5 to 1)

Below them, recording-id coverage reads as a column: from tags, looked
up, none, and of the none how many are waiting, no match, ambiguous or
failed. There is a "Look up now" button and a folded list of the tracks
the lookup could not settle.

Off, keyless and stopped-short passes are each a visible state with the
reason (rule 164).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:29:34 -04:00
bvandeusenandClaude Opus 5.5 f34423a0e0 feat: leveled FLAC stream for Sonos/UPnP speakers (M464 #5001)
release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
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 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.

Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:31:07 -04:00
bvandeusenandClaude Opus 5.5 f3196b3443 feat(library): measure every track's loudness in the background (M464 #4995)
release / go (push) Failing after 1m18s
release / govulncheck (push) Successful in 35s
release / web (push) Successful in 1m27s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
release / integration (push) Failing after 4m13s
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
The first step of loudness normalization: the server measures each track
with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the
integrated loudness, true peak and loudness range in track_loudness
(migration 0065).

It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album
loudness can be computed exactly later with no second decode (#4996). The
histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the
captured fixture), and the analyzer logs a warning if the two ever drift.

- A background worker, cloned from the fingerprint backfill, measures every
  track, new ones included. Measuring inline in the scan was dropped: the
  analysis decodes the whole file, and a large import could pass the scan's
  one-hour stuck threshold. The scan only deletes a changed file's
  measurement; the worker ticks every 10 minutes.
- Timeouts, the cancel/missing-binary split and settled verdicts follow the
  fingerprint runner. Silence and undecodable files are stored as verdicts;
  stalls are retried. The deadline scales with track length.
- loudness_settings (enabled, files at once) and an admin card with the
  coverage gauge, under GET/PUT /api/admin/library/loudness-settings and
  GET /api/admin/library/loudness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:01:01 -04:00
bvandeusenandClaude Opus 5.5 46194a609d fix(auth): build password-reset links from an operator-set public address, never the Host header (M462 #4981)
test-go / test (push) Successful in 1m29s
test-web / test (push) Successful in 1m37s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
test-go / integration (push) Canceled after 2m45s
release / Build signed APK (releases and dev) (push) Canceled after 3m40s
buildResetURL used r.Host and r.TLS, so a forgot-password request with a
forged Host emailed the victim a real reset token on a link to the
attacker's server. Links now come only from network_settings.public_url
(migration 0062), and no reset email is sent while it is empty; the response
stays the same opaque 200 and the log says why.

The address is set on a new "Public address" card under Admin → Integrations,
which offers the page's own origin and warns while unset. PUT
/api/admin/network-settings takes either field alone, so the proxy card and
this one can't overwrite each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:32:10 -04:00
bvandeusenandClaude Opus 5 077ae61235 feat(admin): fingerprinting settings — on/off, length, match threshold, concurrency, sweep interval (M400 #3913)
test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
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 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed
singleton (migration 0061), edited from a card on the Duplicates page and
shared live with the scanner, the backfill and the duplicate sweep through
one service instance, so a save needs no restart.

The length is the knob that can silently break the library: prints taken
at two lengths never match. Each track_fingerprints row now records the
length it was taken at, and every reader filters on the current one — the
backfill treats another length as stale, the gauge counts it pending, the
sweep never streams it. Equivalent to a version bump, except that setting
the length back makes rows not yet redone current again. The card warns
before a length change re-fingerprints the library.

Off stops every decode: the scan takes only the stream hash (a demux, and
what recognises a moved file) and stores nothing, dropping a changed file's
stale row; the backfill idles. A save also makes a sweep due, since a new
threshold or length changes what the same prints group into, and the sweep
interval gains slack so an hourly interval on an hourly tick doesn't skip
every other tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:53:55 -04:00
bvandeusenandClaude Opus 5 11ef044ef6 feat(library): merge duplicates without losing history (M400 #3911)
test-web / test (push) Successful in 57s
test-go / test (push) Successful in 1m16s
test-go / integration (push) Successful in 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m46s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.

In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
  lidarr_requests.matched_track_id and playlist_tracks. The last is
  keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
  a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
  would point a track at itself and keeping the kept copy's existing
  edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
  marks the group merged
- logs sync changes: track deletes, and like and playlist-track
  delete/upsert pairs

The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.

tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.

POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.

On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
  removes, with the consequence stated beside an opt-in Lidarr checkbox

Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
  deduped at the earlier time, plays and skips counted, playlist
  position unchanged, tags unioned, similarity rewritten with no
  duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:25:06 -04:00
bvandeusenandClaude Opus 5 ff493a8c7d feat(admin): the duplicates report — review proposed duplicate groups (M400 #3912)
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
A new admin tab, Duplicates, beside Missing files: the proposals from
the duplicate sweep, with a Sweep now trigger and a Not duplicates
dismissal. Nothing on it merges or deletes; the merge is #3911.

Each group shows:
- whether it is identical audio or the same recording, with a match
  percentage from the weakest link between members
- every copy's format, size, duration, path, and the likes and plays
  it carries (every user's; this is admin-only, and it is what decides
  which copy to keep)
- the copy proposed to keep, and the rule that chose it

The survivor rule is library.ProposeSurvivor, a pure function the
merge will reuse: lossless over lossy, then the larger file, then the
copy in the library longest, then lowest id. Bitrate is not in it
because the scanner never fills tracks.bitrate, and for one recording
at one duration a larger file is the higher bitrate. m4a is not counted
as lossless: it may be AAC. The reason names the rule that separated
first place from second, not every rule the winner passed.

An empty report has three causes, and the page says which: still
fingerprinting, the sweep has never run, or it ran and found nothing.
The sweep's state and the backfill's progress come back with the groups
for that reason. Groups left with fewer than two members since the
sweep are not shown.

GET /api/admin/library/duplicates, POST .../sweep (202, or 409
sweep_in_progress), POST .../{id}/dismiss (404
duplicate_group_not_pending when already resolved).

Migration 0060 indexes play_events by track_id. Its only indexes led
with user_id, so each copy's play count, and the merge's repointing of
play history, would scan the whole table.

Web only, like Missing files: Android has no library-health admin
screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 17:11:11 -04:00
bvandeusenandClaude Opus 5 21c698a616 test(web): give the admin page mock the fingerprint coverage query
test-web / test (push) Successful in 35s
release / Build signed APK (releases and dev) (push) Successful in 4m25s
release / Build + push container image (push) Successful in 34s
release / Verify release artifacts (tag releases only) (push) Skipped
b8855b48 made the admin overview page create a fingerprint coverage
query, but admin.test.ts mocks $lib/api/admin with an explicit factory
that only returned the cover coverage query. Every test that renders
the page threw on the missing export (run 6512, 11 failures). The mock
now returns it in the same empty-store shape, so the gauge stays hidden
in these tests the way the cover gauge does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 15:00:38 -04:00
bvandeusenandClaude Opus 5 b8855b480f feat(library): backfill fingerprints for the existing library — M400 #3908
test-web / test (push) Failing after 50s
test-go / test (push) Successful in 1m7s
test-go / integration (push) Successful in 3m27s
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 4m23s
The scan fingerprints only bytes it has not seen, so everything imported
before fingerprinting existed, and any row derived by an older
fingerprintVersion, needs a pass of its own.

That pass is a background worker, not a stage in RunScan. RunScan runs
at boot and then every 12h, and an in-flight scan older than an hour is
reaped and a second started beside it. A stage would have to stop inside
the hour: a few hundred decodes a run, so about a month for a 50k-track
library. It would also hold the run in flight and answer manual
rescans with 409 while it worked.

FingerprintBackfillWorker runs once at start, then hourly. Nothing a
pass does (error or panic) can stop the next tick. A pass walks tracks
with no fingerprint or a stale version, skipping missing tracks,
keyset-paged on id. The cursor is what lets a pass end: an inconclusive
attempt writes no row, so a file that keeps timing out would otherwise
be re-listed and retried forever. Two decodes at a time, deliberately:
they compete with transcoding for CPU and with streaming for the mount.

storeFingerprint is now one package function shared by the scan and
the worker, and reports whether the attempt was fingerprinted,
rejected, inconclusive or failed to store.

Progress is a live gauge on the Admin scan card, served by
GET /api/admin/library/fingerprints: fingerprinted / rejected / pending
of total, with missing tracks excluded so it can reach the end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:56:18 -04:00
bvandeusenandClaude Opus 5 f367eeaa9d fix(recommendation): Songs-like gets its own profile so it stops wandering
test-web / test (push) Successful in 1m7s
test-go / test (push) Successful in 1m31s
test-go / integration (push) Failing after 4m21s
release / Build signed APK (releases and dev) (push) Successful in 5m11s
release / Build + push container image (push) Successful in 1m52s
release / Verify release artifacts (tag releases only) (push) Skipped
Operator, 2026-09-10: "when I play it I'm expecting to get a consistent
sound and style from the experience... I was getting a seeming wide variety
of music from each one when I was hoping to stay in a certain neighborhood."

Songs-like shared the `daily_mix` weight profile with For-You, and that
sharing WAS the bug. The two surfaces want opposite things: For-You answers
"what will they enjoy today" and is supposed to roam; Songs-like answers
"what sounds like THIS". Under one profile the broad answer wins.

The arithmetic, from the shared weights:

    unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
    PERFECT similarity match, not liked         → 1.0 + 1.5       = 2.5

Liking something outranked sounding like the seed, because LikeBoost (2.0)
exceeded SimilarityWeight's whole range (1.5) and TasteWeight (1.5, and
seed-INDEPENDENT) matched it outright. Under the new profile the same pair
scores 5.00 vs 2.00.

Two levers, because either alone leaves the other's failure intact:

POOL. Songs-like now takes its own CandidateSourceLimits. The default gave
~29% of candidates a sim_score of literally zero — `taste_overlap` and
`random_fill` are both `0.0::float8` in recommendation.sql, seed-independent
by construction. Same total pool size; composition shifts to arms that
measure distance from the seed, LBSimilar doubled.

WEIGHTS. A third profile beside radio and daily_mix, DB-backed and live per
rule 25, with the property that similarity's range exceeds the combined
range of every seed-independent differentiator — so a closer match cannot
be beaten on likes, freshness and taste alone, while tracks within ~0.39
similarity of each other still get ordered by what the user likes.

Rule 131 changed the pool design mid-way and for the better. Zeroing the
two seed-independent arms was the first instinct and is exactly the
vanish-or-nothing shape that rule forbids: a seed with thin ListenBrainz
coverage would yield a short mix or none. They are the tier-3 FLOOR — cut
hard, never removed — and the weights keep them at the bottom of the
ranking rather than out of the pool. "A few tracks further from the seed
than we'd like" beats "no playlist".

Caught while wiring it: switching only pickTopN's final Score would have
been nearly INERT. scoreAndSortCandidates does the selection sort, and the
caller caps and truncates in that order — so the playlist would still have
been chosen by daily_mix and merely relabelled with songs_like numbers. It
now takes the profile as a parameter, and each surface passes its own.

Also corrects the daily_mix card's blurb, which claimed Songs-like as one
of its surfaces and no longer is.

Guards pin behaviour rather than the numbers, since numbers get retuned:
that similarity beats an unrelated liked track, that daily_mix still
DOESN'T (or the split buys nothing), that the tier-3 floor is non-zero,
and that the UI card shows its own values rather than falling back. Each
falsified against its named regression first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-10 20:51:15 -04:00
bvandeusen 6d729d1512 fix(web): timeUntil rounds, so a 4h wait doesn't read as 3h — #2527
test-web / test (push) Successful in 34s
CI caught a real bug, not just a brittle test. The page rendered an
attempt four hours away as "in 3h", because timeUntil floored the way
relativeTime does.

Flooring an elapsed time is honest: "3h ago" means at least three hours
have passed. Flooring a countdown is not -- 3h59m away became "in 3h",
so the operator comes back an hour early and finds nothing has happened.
It rounds now, with the boundary cases pinned: sub-minute is "any
moment", 59.6m is "in 1h", 24h is "in 1d".

That divergence is now the fourth documented difference between the two
formatters, all deliberate, all in snippet #2699 with a test asserting
they disagree so nobody unifies them later.

The test assertions were also genuinely wrong: they read raw textContent
from a template that wraps mid-sentence, so "last 2d ago" arrived as
"last\n                2d ago". Added a whitespace-normalising helper —
asserting on raw textContent makes a test fail when the markup reflows,
which says nothing about the behaviour.
2026-08-17 00:25:40 -04:00
bvandeusen 414dfb23b6 feat: show what re-acquisition has done, per folder — #2527
test-web / test (push) Failing after 42s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m3s
Completes milestone #290. The sweeper has been running and the settings
have been editable, but the list itself said nothing about either, so
the only way to tell "not tried yet" from "asked twice and nothing came
back" was to go and read the Requests queue.

Each folder now carries its album's attempt record: how many times, when
last, when next -- or that it gave up, with the reassurance that a file
coming back and going missing later starts the process over. Null when
nothing has been attempted, which is the common case for a folder that
just went missing and would be noise on every row.

next_attempt_at is computed, not 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 -- and the card lets
them do exactly that.

Needed a forward-looking formatter. relativeTime deliberately collapses
a future timestamp to "just now" (pinned by its own test) because that
is the right answer for a clock-skewed past event; it is the wrong one
for a scheduled future attempt, which would have rendered "next just
now". timeUntil is its companion rather than a sign-aware rewrite: the
two read differently in the same sentence -- "last tried 3d ago, next in
4h" -- and a test asserts they disagree about the future on purpose, so
nobody later "fixes" the divergence.

The state lookup is one batched query for the whole page and best-effort:
this is context on a list whose real job is showing what is missing, so
a failure leaves the groups bare rather than failing the page. The
settings service is read with a nil guard falling back to the shipped
defaults, since contexts that wire routing without services exist and a
backoff projection is not worth a nil-pointer panic (rule #48).
2026-08-17 00:19:48 -04:00
bvandeusen 952132714e feat(web): re-acquisition settings card on the missing-files page — #2527
test-web / test (push) Successful in 34s
Rule #27: the sweeper has been running since bab9b168 with no way to see
or change what it does. This is the half that makes it a feature.

Placed above the list it governs rather than under Integrations. An
operator looking at missing files is exactly the person deciding what
should happen to them; Lidarr is the mechanism, not the subject, and
separating the policy from the problem would mean finding one to
understand the other.

The card states the retry schedule the numbers add up to -- "6h -> 12h
-> 24h" -- because the fields are meaningless individually. "First retry
gap: 6" tells you nothing until you know it doubles and where it stops,
and an operator should not have to simulate the algorithm to predict it.
It recomputes as they type, including the clamp.

It also states the unnameable-album count with its reason. Those albums
will never produce a request no matter how long they sit in the list
below, because Lidarr cannot be asked for a release MusicBrainz cannot
name. Watching rows never move with no explanation is how a working
feature gets reported as broken.

Save errors surface the server's own message. The Go layer validates the
same ranges the CHECKs enforce and names the field, so the operator
reads "grace_hours must be 1-720" rather than a generic failure.

The dirty check compares only the stored fields: unnameable_albums is
server-computed, and including it would make the form look edited
whenever the library changed underneath.

Nine tests, including the schedule clamp, the disabled-until-dirty Save,
the surfaced validation message, and a failed load offering a retry
instead of an empty card. The existing missing-files page suite gains a
stub for the card's own settings fetch -- it mocks the whole admin API
module, so the card's imports would otherwise be undefined at mount.
2026-08-17 00:13:20 -04:00
bvandeusen 8d1f2674fd feat(web): admin page for files the library has lost — #2527
test-web / test (push) Failing after 32s
Renders GET /api/admin/library/missing under Admin -> Missing files.
Folder-grouped, because that is the unit an operator decides about: the
case behind #2523 was three reorganised albums, and forty individual
rows hides that it is really three decisions.

Each row leads with the fact that settles whether a missing file is
worth chasing -- "last played 2d ago" against "never played". The group
header carries how many tracks and how long they have been gone.

Read-only. No remove button anywhere: the row, its play history and its
likes survive a file going missing, and the scanner clears the mark by
itself when the file returns (or adopts the row if it returns renamed,
#2528). The page says so in its own copy rather than leaving the
operator to infer it.

Empty state explains the feature instead of the emptiness -- what puts a
row here (moved outside Minstrel, deleted, a drive that didn't mount)
and that rows leave on their own. Someone who has never seen this page
should not have to guess.

Paging follows the house pattern -- plain offset into the factory,
wrapped in $derived so a page change re-creates the query with a new
key. Passing a getter instead would capture the key once and paging
would silently not refetch. The pager only renders when it can do
something.
2026-08-16 12:03:59 -04:00
bvandeusen 845f45fb0b refactor(web): one relativeTime for the triage surfaces — #2527
test-web / test (push) Successful in 40s
Admin quarantine, admin playback-errors and library/hidden each carried
a byte-identical private copy of the same coarse "3d ago / 5h ago /
12m ago / just now" formatter. Writing the missing-files surface would
have made it four, so extract it instead.

They are one concept, not three that happen to look alike: each shows
the age of something an operator is deciding about, and they have to
agree -- a row reading "2d ago" on one screen and "2 days" on another
makes the reader wonder whether the two mean different things.

Three near neighbours are deliberately NOT folded in, because they are
different intents rather than drifted copies:
  - HistoryRow shows a weekday and clock time under a week ("Tue 21:40"):
    for listening history, WHEN you played something beats how long ago.
  - ActiveSessions.when() writes prose ("1 hour ago", "yesterday") and
    falls back to a locale date past 30 days -- a security surface where
    the longer form reads better.
  - PlaylistCard.refreshedLabel() is day-boundary aware and prefixed
    ("Refreshed today"), and already carries a comment saying it is
    deliberately not the m/h-ago style.
Merging any of those would mean forcing one caller's wording onto
another, which is the wrong-abstraction failure, so they stay put.

Tests pin the boundaries the copies never covered: each unit step, that
only the largest whole unit is reported (25h is "1d ago", never
"1d 1h ago"), and that a future timestamp from a skewed client clock
degrades to "just now" instead of rendering a negative age.
2026-08-16 12:01:13 -04:00
bvandeusen 304de88c50 test(tuning): assert headers by exact accessible name — #2495
test-web / test (push) Successful in 34s
Third attempt at the same assertion, so I stopped guessing and got vitest
running locally instead: the web lane uses the same ci-go image, so
`docker run ... -w /src/web ci-go:1.26 npx vitest run` works and turns a
5-minute CI round trip into 7 seconds.

/^Skip/ matched the "Skip rate by week" sparkline column as well as
"Skip (last wk)", just as /Plays/ had matched the caption. Exact names
say what the assertion means and cannot drift onto a neighbour.

Verified locally before pushing: svelte-check 0 errors, 110 files /
786 tests pass.
2026-08-06 21:27:22 -04:00
bvandeusen 96abb48086 test(tuning): query the window/last-week headers as column headers — #2495
test-web / test (push) Failing after 33s
getByText(/Plays/) matched my own new caption as well as the header, since
the caption explains which columns cover the window. Query by columnheader
role instead, which is what the assertion actually means.

Also reordered the caption: prepending the clarification turned it into a
run-on that opened mid-explanation before saying what the chart was.
2026-08-06 21:20:18 -04:00
bvandeusen a094d5f8b0 test(metrics): target deltas by test id, not by glyph — #2495
test-web / test (push) Failing after 35s
Two CI failures, both in my own new tests, both informative.

settings: queryByText(/≈/) matched the LEGEND explaining the glyph rather
than a delta, so the "no delta" case failed on the explanation being
present. Delta spans now carry data-testid so a test can name what it
means instead of pattern-matching prose that sits next to it.

tuning: getByText("40%") found two elements. testing-library matches an
element and its OWN direct text nodes, so the skip cell still matches
"40%" despite the trailing play-count span — and discover late-week
completion is also 40%. Genuinely ambiguous now; assert the count.
2026-08-06 21:14:53 -04:00
bvandeusen 481f906059 feat(metrics): publish margin of error on every delta — #2495, #2524
test-web / test (push) Failing after 43s
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 4m58s
The metrics card had one volume threshold doing two jobs.
recMetricsLowVolume = 20 is a DISPLAY floor — below that a skip rate is
anecdote — but the card then presented deltas as though it were also a
DECISION floor. Those differ by an order of magnitude: detecting the ~13pp
differences that matter needs ~133 plays per arm for 80% power at a=0.05.

So Discover's taste-matched (59 plays) and random-unheard (70) both rendered
as full-confidence rows with a bold delta beside them, and that comparison
sits at p ~ 0.06. The card said "signal"; the arithmetic said "maybe". It
produced a recommendation the data didn't support, and any reader with the
same numbers would have made the same call.

Deltas now carry a 95% margin of error and a `distinguishable` flag, computed
server-side so both clients read the same arithmetic instead of each
re-deriving it. Skip rate is a two-proportion difference; completion is
Welch, which needs a variance — hence completion_sqsum in the query. It is
the sum of squares rather than stddev_samp on purpose: raw source rows are
merged into surface families in Go, and sums of squares combine across groups
exactly whereas standard deviations cannot.

recMetricsLowVolume is untouched. "Too thin to show" and "too thin to act on"
are different questions.

Web renders an indistinguishable delta as dimmed and prefixed "≈", with the
range on hover and a legend explaining the glyph. Colour is withheld unless
the delta clears its margin — colouring noise red is what made the old card
misleading. Breakdown rows go through the same path; those are the thinnest
samples on screen and where the old card misled most.

Also fixes the admin trends view, which had the same problem worse: its
"Latest skip"/"Latest completion" columns are one WEEK while the adjacent
Plays column is the whole window. I misread exactly that and briefly
concluded Deep cuts was the worst surface, from ~17 plays in a single week —
over 180 days it is one of the best. Headers now name their period and the
skip cell carries that week's play count.

#2524: resolveArtist now recognises a duplicate-MBID unique violation as the
expected condition it is, matching resolveAlbum. Two rows mapping to one
MusicBrainz artist is a merge candidate, not a fault; without the branch it
logged a generic warning plus a Postgres ERROR line on every scan, which
teaches an operator to ignore database errors.
2026-08-06 21:08:58 -04:00
bvandeusen 381e9cedb7 feat(net): trusted-proxy depth so real client IPs survive a proxy — #2453
test-go / test (push) Failing after 50s
test-web / test (push) Failing after 50s
test-go / integration (push) Failing after 2m19s
Fixes the defect the operator spotted in #370 immediately after it shipped:
auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a
proxy on a public address — a separate host, or a CDN, i.e. anyone running
this publicly, since public means TLS means a proxy — recorded the PROXY for
every session. created_ip and last_ip were then always equal and the
"Address changed" signal could never fire. The feature looked like it worked
and reported nothing.

Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx).
XFF grows left-to-right as each proxy appends the peer it received from, so
for client -> CDN -> own-proxy -> app the app sees [client, CDN] with
RemoteAddr = own-proxy, and the client sits at XFF[len - hops]:

  0  RemoteAddr, XFF ignored — no proxy
  1  the address your own proxy observed
  2  through a CDN in front of your proxy

Default 1, per the operator: publicly reachable means a TLS terminator in
front.

The cost is real and stated rather than hidden. hops >= 1 DECLARES that a
proxy exists; set it with no proxy, or deeper than the actual chain, and the
index reaches attacker-supplied entries, letting a visitor choose which
address their own session shows — defeating exactly the detection #370 is
for. That's inherent to the model, which is why 0 is a first-class value and
the admin card says "count your proxies, don't guess high" instead of just
exposing a number. Both mis-set shapes are pinned by tests so they stay known
consequences rather than surprises.

Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an
optimisation: ClientIP runs in RequireUser for every authenticated request, so
a per-request query would put the database on the critical path of the whole
API. New() always returns a usable service so a boot-time DB hiccup degrades
to the default instead of breaking that path (rule #131), and Hops() is
nil-safe because test routers construct middleware without it.

RequireUser now takes a func() int rather than an int — the value is
operator-editable at runtime while the middleware is built once at boot, and
reading it per request is what makes a save take effect with no restart
(rule #25).

The admin card is verifiable, not just configurable: it reports the address
the CURRENT setting resolves THIS request to, the raw forwarded chain, and the
socket peer — so you set the number, save, and confirm the address matches the
machine you're on. It also counts the arriving chain and says how many proxies
that implies. GET/PUT both return that payload, PUT recomputed under the new
value, so the effect is visible without a reload.

Also fixes styling in the #370 card that CI could not catch: text-destructive
and bg-destructive don't exist in this Tailwind config — the palette is
colors.action.destructive — so the "Address changed" warning and the
sign-out-others button were rendering unstyled. Both now use
text-action-destructive / bg-action-destructive / text-action-fg.

Not done here: requestlog.go still logs raw RemoteAddr and will disagree with
the sessions UI about who connected. Left for its own change.
2026-08-05 10:07:43 -04:00
bvandeusenandClaude Opus 5 ca4832e620 feat(discover): Discover tuning card on the admin lab — #2377 (web admin)
test-web / test (push) Successful in 35s
Rule #25/#27: the two knobs slice 6 added server-side are now touchable —
taste-tag weight and snooze length, with deviation dots, save, and reset,
matching the existing profile/taste cards.

Copy states what each knob does AND what it doesn't: the tag-weight hint
says 0 turns the term off and that an untagged candidate is never
penalised, and the snooze hint says it records no opinion about the artist
and never feeds the taste profile. Those are the two properties most likely
to be assumed backwards by whoever turns these next.

Also fixed a latent fragility the new card exposed rather than caused: all
three reset buttons had the accessible name "Reset to defaults", so the
existing test picked the LAST one and assumed that meant taste. Adding a
card below it would have silently retargeted that assertion at the wrong
scope. Each reset button now names its scope — better for screen readers
too, since three identical buttons on one page is a real a11y defect — and
the test selects by name instead of position.

The page's test fixture needed the new `discover` key in both `snapshot`
and `shipped`: the `as TuningSnapshot` cast means a missing field is not a
compile error, it's every test on the page throwing inside fillForm. Noted
that in the fixture so the next scope doesn't rediscover it.

Includes a test that a weight of 0 is actually SENT rather than dropped as
falsy — the off switch is the one value a truthiness bug would eat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:49:17 -04:00
bvandeusenandClaude Opus 4.8 f0c08e7326 feat(taste): mood taste facet — #1534
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m44s
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste
facet alongside artists + genre tags + eras: signed weights over canonical
mood buckets (melancholic / energetic / chill / …) derived from a track's
enriched folksonomy tags (#1490).

- internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to
  canonical mood buckets (synonyms collapse). Imported by both the taste
  builder and the scorer so a track's mood is derived identically.
- Migration 0047: taste_profile_moods table + taste_tuning.mood_scale
  (DEFAULT 0.5).
- Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors
  EraScale); accumulate folds each play/like's mood buckets at
  base*MoodScale; persist atomic-replaces the mood rows.
- Scorer (internal/recommendation): TasteProfile gains a mood term
  (own tanh scale + additive 0.12 share, so it never weakens the existing
  signal when a track has no mood tags). Match now takes the candidate's
  mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in
  the primary similarity loader only — the near-whole-library fallback
  pool passes nil (mood → 0) to avoid a full-library tag scan.
- Tuning lab: mood_scale threaded through recsettings + admin API + web
  card ("Mood weight" row) + Go/web tests.

Coverage is partial (grows with tag enrichment; richer once Last.fm is
keyed), so mood is a supplement — neutral for tracks with no mood tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:32:41 -04:00
bvandeusenandClaude Opus 4.8 65dd132b3d feat(taste): time-of-day / weekday context conditioning — #1531
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 42s
test-go / integration (push) Successful in 4m46s
Milestone #160 Opt 3 (temporal half). A new additive scoring term that
boosts a candidate when its artist's play history concentrates in the
CURRENT daypart × weekday-type cell, in the user's local timezone.

- Migration 0046: recommendation_weight_profiles.context_time_weight
  (per-profile scoring weight, DEFAULT 1.0).
- Query ListArtistContextPlayCountsForUser: per-artist completed-play
  counts split by the current cell (daypart night[22,5)/morning[5,12)/
  afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via
  started_at AT TIME ZONE users.timezone; 365-day window, skips excluded.
- internal/recommendation/context.go: LoadContextAffinity computes each
  artist's shrunk cell-share minus the user's baseline share, clamped to
  [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown
  artists → 0 (cold-start neutral).
- Score() gains context_affinity_score · ContextTimeWeight; both
  candidate loaders set it per candidate.
- Tuning lab: ContextTimeWeight threaded through recsettings + admin API
  + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both
  profiles (uniform start, re-bakeable).

Device-class axis deferred to #1551 (needs a client_id → device-class
mapping that doesn't exist yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:31:43 -04:00
bvandeusenandClaude Opus 4.8 40384cc05e feat(taste): era/decade taste facet — #1530
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m40s
Milestone #160 Opt 2 (era half). A third taste facet alongside artists
+ genre tags: signed weights over decade buckets ("1990s") derived from
albums.release_date, rebuilt daily and scored into the taste match.

- Migration 0045: taste_profile_eras table (mirrors taste_profile_tags)
  + taste_tuning.era_scale column (DEFAULT 0.5).
- Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors
  EnrichedTagScale), accumulate folds each play/like's decade at
  base*EraScale, persist atomic-replaces the era rows.
- Scorer (internal/recommendation): TasteProfile gains an era term (own
  tanh scale + additive 0.15 share so it never weakens the existing
  artist/tag signal when a track is undated); candidate queries return
  album release_date; decadeOf mirrors the builder helper.
- Tuning lab: era_scale threaded through recsettings + admin API + web
  card (auto-renders the new row) + Go/web tests.

Mood facet deferred to #1534 (partial enrichment coverage + needs
candidate-side enriched-tag loading).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:01:00 -04:00
bvandeusenandClaude Opus 4.8 2b3be8311a feat(tuning): expose EnrichedTagScale in the tuning lab (#1520)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m45s
Promote the enriched-tag weight (#1490) from a taste.Config default into
the DB-backed tuning lab so operators can dial how much folksonomy tags
count vs raw ID3 genre (rule #25).

- Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills
  the existing row).
- recsettings: TasteTuning gains the field; seeded/read/updated through
  reconcile + persistTaste; applyTastePatch validates it to [0,1]
  (generic non-half-life clamp) and diffTaste audits it; TasteConfig maps
  it into the profile build.
- API: tasteTuningResp exposes enriched_tag_scale.
- Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre
  only). Tests: recsettings persist+range, web fixture field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:51:36 -04:00
bvandeusenandClaude Opus 4.8 797ed1f5ad feat(web): tag enrichment sources admin card (#1490 Step 4 web)
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m45s
Add a "Tag enrichment sources" card to the admin integrations page,
mirroring the cover-art providers card: per-provider enable toggle +
API-key field + Save + Test connection, over /api/admin/tag-sources.
Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled
tracks for re-enrichment via the version bump.

- admin.ts: TagProvider types + get/update/test functions +
  createTagProvidersQuery; qk.tagProviders key.
- integrations/+page.svelte: the card + local edit state, with
  MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes.
- Tests: admin.tag-sources API test + integrations component tests
  (render / enable+key save / test connection), plus the mock plumbing
  the existing suite needs for the new query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:33:21 -04:00
bvandeusenandClaude Fable 5 29e1e7c64c test(web/tuning): marker summary appears in tooltips too — use getAllByText
test-web / test (push) Successful in 32s
The knob-turn summary renders both as the sparkline tick tooltips (one
per series) and in the list under the chart; the single-element query
tripped on the duplicates in CI run 1906.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:35:51 -04:00