Commit Graph
2015 Commits
Author SHA1 Message Date
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 01e2294471 ci: probe and relax the integration Postgres over TCP, with retries (#5426)
release / web (push) Successful in 1m34s
release / govulncheck (push) Successful in 24s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 5m21s
release / android (push) Successful in 5m33s
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 13s
release / Verify release artifacts (tag releases only) (push) Skipped
On first boot the postgres image runs initdb under a temporary server that
listens on the unix socket only, then stops it. The socket readiness probe
could catch that server, and the fsync relax then hit 'the database system
is shutting down', leaving the suite ~15x slower on internal/api. Only the
real server listens on TCP, so probe and relax via -h 127.0.0.1, retry the
relax, and print the settings that took effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:18:49 -04:00
bvandeusenandClaude Opus 5.5 bf0bc45c4b fix(web): filter a refetched mix inside the self-heal step, not by wrapping the closure (#5418)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m42s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / integration (push) Successful in 17m42s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 22s
release / Verify release artifacts (tag releases only) (push) Skipped
The wrapper added a promise hop, so the self-heal test's single
microtask wait saw 'loading' instead of 'error'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:33:10 -04:00
bvandeusenandClaude Opus 5.5 8bd2f66416 fix(web): missing files show greyed in track lists and never enter the queue (#5418)
release / web (push) Failing after 1m23s
release / govulncheck (push) Successful in 25s
release / go (push) Successful in 1m35s
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 4m34s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s
TrackRef gains the unavailable flag the server already sends on album
detail and search. TrackRow greys such a row with '· File missing' and
hides like, radio, add and the menu, as playlist rows already do. Every
player-store entry point (playQueue, enqueue, play next, and a refetched
mix) drops missing files; playQueue keeps the chosen track as the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:28:36 -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 918db27fec feat(web): shared collection header, Play and Shuffle, and the centred page column (M494)
release / govulncheck (push) Successful in 42s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 5m51s
release / android (push) Successful in 6m53s
release / Build signed APK (releases and dev) (push) Successful in 7m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
- CollectionHeader holds cover, title, facts, then Play and Shuffle, for
  album and playlist. The playlist page gains Play and Shuffle; it had
  neither. The cover uses the shared Cover component, so the playlist
  header also gets the fallback image.
- PlayActions is the one Play and Shuffle pair: filled Play, outlined
  Shuffle, on album, playlist and artist pages (operator, 2026-10-08).
  The artist page's round icon button said Play but shuffled; Play now
  keeps the server's order, and Shuffle shuffles.
- PageColumn is the playlist page's centred column, now used by album,
  playlist, Liked, History and Search → Tracks.
- The playlist page builds its queue with playlistTrackToRef, the shared
  converter, in place of its own copy. Its copy skipped only removed
  tracks, so a missing file (#2527) could still be queued from there.
- shuffled() in lib/utils is the one shuffle, replacing three private
  copies (album page, artist page, ArtistCard).
- The album's refetch error now uses text-error-fg. `text-oxblood` is
  not a colour in the theme, so it never rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 aaa6fa15d6 test(web): import history components after their mocks (M494)
HistoryRow now renders LikeButton, and both history test files imported
the component before the likes mock helper. LikeButton then loaded while
emptyLikesMock was still uninitialised, and the suites failed at import.
Every other suite imports components after its mocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:44 -04:00
bvandeusenandClaude Opus 5.5 b7f50c42a9 feat(web): one track-list row shape, and drag reorder that snaps (M494, #5393)
release / govulncheck (push) Successful in 34s
release / go (push) Successful in 1m37s
release / web (push) Failing after 1m20s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m49s
Every track list (album, Liked, Search, Search → Tracks, playlist,
History) now draws its rows with TrackListRow inside a TrackList box,
in place of four row components that had drifted apart:

- TrackListRow owns the look: lead column, title over "artist · album",
  like, radio, add to queue, menu, duration, then a trailing slot. It
  also owns the stripe, hover, the now-playing band and the
  unavailable state. The stripe is dropped on now-playing and selected
  rows, where Tailwind would otherwise let `odd:` win.
- TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only
  their behaviour: track number and multi-select; grip, remove and
  dead-entry states; cover and timestamp. Playlist and History rows
  gain radio, add and the menu, so every list offers the same actions.
- The radio control is Lucide's Radio icon, not the 📻 emoji.
- Enter on a control inside a row now belongs to that control. The old
  TrackRow played the track when Enter was pressed on "add to queue".
- TrackList has no fill, so the stripe shows on playlists too.

Drag reorder (#5393): neodrag kept the drop offset, so after the list
re-rendered the dragged row sat between two slots. reorderDrag, shared
by the playlist and queue rows:
- snaps the row to whole rows while dragging;
- resets it to its slot on drop;
- on playlists, gives the touch gesture to the grip only, so the list
  still scrolls by touch.
The playlist page reorders optimistically and rolls back on error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:06 -04:00
bvandeusenandClaude Opus 5.5 e16d182417 fix(web): drop the shelf-header accent rule Tailwind 4 started drawing (#5021)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 4m47s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
The rule after each shelf title used `after:bg-accent/60`. Tailwind 3
cannot put an opacity modifier on a `var()` colour, so it emitted nothing
for that class and the rule never showed. Tailwind 4 mixes the opacity
in with color-mix, so the rule appeared after the upgrade. Removed, so
the headers look as they did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:17:15 -04:00
bvandeusenandClaude Opus 5.5 ca9917d8cd chore(web): clear kit 3's alias and Vite's config-loader deprecations (#5021)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 4m49s
release / android (push) Successful in 5m32s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
CI run 8789 warned on both:

- kit 3 deprecates `config.alias`. The $test-utils alias becomes a
  `#test-utils/*` subpath import, matching #lib, and its 43 import sites
  move with it.
- Vite's coming native config loader needs the extension on
  vitest.config.ts's import of vite.config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:03:38 -04:00
bvandeusenandClaude Opus 5.5 b1b10b0c77 fix(web): run Tailwind 4 through @tailwindcss/vite, not PostCSS (#5021)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 56s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m43s
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 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
The image build on fecd030b failed in `vite build`:

  [postcss] ENOENT: no such file or directory, open '/web/tailwindcss'

With a PostCSS config present, Vite's own @import inliner resolves
`@import 'tailwindcss'` before @tailwindcss/postcss sees it, and reads it
as a relative file. svelte-check and Vitest never build CSS, so only the
image job caught it. The Vite plugin is Tailwind's documented setup for
Vite projects and handles the import itself, so postcss.config.cjs and
the direct postcss and @tailwindcss/postcss dependencies go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:55:48 -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
bvandeusen beba5b2082 Merge remote-tracking branch 'origin/renovate/sveltejs-kit-3.x' into dev 2026-10-08 10:49:44 -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
Renovate Bot 0202c86338 chore(deps): update dependency tailwindcss to v4
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-10-08 14:45:03 +00:00
Renovate Bot 0500f6aac5 chore(deps): update dependency @sveltejs/kit to v3
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:01 +00:00
Renovate Bot c83933b61a chore(deps): update dependency @sveltejs/adapter-static to v4
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:45:00 +00:00
bvandeusenandClaude Opus 5.5 308045d056 chore(deps): vite 8, vite-plugin-svelte 7 and vitest 5 together (#5021)
release / govulncheck (push) Successful in 33s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m50s
release / integration (push) Successful in 4m57s
release / android (push) Successful in 6m25s
release / Build signed APK (releases and dev) (push) Successful in 6m37s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m13s
release / Verify release artifacts (tag releases only) (push) Skipped
Merges Renovate's three branches (PRs #145, #146, #147), which fail
alone: vite-plugin-svelte 7 requires vite 8, and vitest 5 is the vitest
for vite 8. Renovate changed only package.json, so npm ci failed on each.

The lockfile is regenerated for just these packages: the stale entries
for vite, vitest, @vitest/* and vite-plugin-svelte (with its old
inspector) were dropped and re-resolved, leaving everything else locked.
SvelteKit stays on 2.70.3, which accepts vite 8 and plugin 7. Vite 8
builds with Rolldown, so esbuild moves to 0.28 and rollup leaves the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:24:51 -04:00
bvandeusen 5d5af359b6 Merge remote-tracking branch 'origin/renovate/vite-8.x' into dev 2026-10-08 10:23:28 -04:00
Renovate Bot 67f1975f53 chore(deps): update dependency vitest to v5
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-10-08 14:19:20 +00:00
Renovate Bot 5dfbe1361e chore(deps): update dependency vite to v8
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:19:18 +00:00
Renovate Bot 39c33e4306 chore(deps): update dependency @sveltejs/vite-plugin-svelte to v7
renovate/stability-days Updates have met minimum release age requirement
renovate/artifacts Artifact file update failure
2026-10-08 14:19:16 +00: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 9940bc375d feat(android): notifications when the app is closed, a specialUse delivery service (#5347)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m31s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 6m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m13s
release / Verify release artifacts (tag releases only) (push) Skipped
Roundtable's shape: no FCM. A specialUse foreground service keeps the
process alive; EventsStream stays connected; DeliveryLauncher runs the
catch-up on every notification.created nudge and every reconnect, and is
the single owner of the service's lifetime (signed in AND the device's
"Notifications when the app is closed", on by default).

- NotificationSync pulls the newest page and announces unread notices past
  a high-water mark (auth_session.notifiedUpTo, read and written through
  the DAO), honouring the per-kind phone pref. A first look sets the mark
  without announcing; more than three collapse to one line; nothing is
  posted while the app is on screen.
- Two channels: "Your requests" and "Library health"; the ongoing notice
  sits on a MIN "Background connection" channel.
- EventsStream: a connected flow, 2s→5min backoff with ±25% jitter, and an
  immediate reconnect when a network comes up (a hint, not VALIDATED) or
  the app comes to the foreground.
- BootReceiver restarts delivery after a reboot or a self-update.
- A tap opens what the notice links to (routeForLink), a pile the inbox.
- POST_NOTIFICATIONS is asked for on Android 13+ once delivery is wanted,
  and again when the toggle is turned on.
- Room v12: auth_session.backgroundDelivery and notifiedUpTo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:59:11 -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 62f76290fb fix(android): split notification kinds out of the replayer's dispatch (detekt)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 4m39s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
dispatch reached cyclomatic complexity 16 with the three M489 kinds; they
now share one entry that hands off to dispatchNotification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:38:43 -04:00
bvandeusenandClaude Opus 5.5 c416157a1b feat(android): notifications bell, inbox screen and settings, offline-first (#5343, #5345)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Failing after 1m41s
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 2m49s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
- The top bar carries a bell with a badge. The badge is inverse, not
  error red, and counts to 9, then shows 9+. It opens the Notifications
  screen: one line per notice, how long ago, "Mark all read", pull to
  refresh. A tap marks the notice read and opens its screen (albums,
  artists, requests, admin requests and quarantine; other admin pages
  land on Admin).
- The newest 50 notices live in Room (cached_notifications, v11 with
  MIGRATION_10_11), so the badge and the list work offline. A refresh
  keeps a read made here that the server hasn't seen yet.
- Reads, read-all and setting toggles go through the MutationQueue
  (rule 100): NOTIFICATION_READ, NOTIFICATIONS_READ_ALL and
  NOTIFICATION_SETTING_SET.
  - Read-all sends the newest notice shown, rounded up a millisecond
    (Room keeps ms, the server µs), so a late replay leaves newer notices
    unread.
  - Settings collapse per kind and channel.
- The `notification.created` live event, a return to the foreground and
  reconnecting all refresh the inbox.
- Settings → Notifications: a row per kind with Inbox, Phone and Email.
  Admin kinds sit under "Library health". Phone and email ride on the
  inbox. One line says why email is off. If the system blocks
  notifications, a row opens Minstrel's notification settings; it is
  re-checked on resume.
- Signing out clears the cached inbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:35:56 -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 87f8ed6147 feat(web): notification settings per kind and channel (#5345, web half)
- A Notifications section on Settings has a row per kind and a toggle
  each for Inbox, Phone and Email. Labels are short, menu-style.
- Admin kinds sit under "Library health", for admins only.
- Toggles are optimistic and send only the kind and channel touched. A
  failed save reverts unless something newer has happened (snippet
  #5106's generation counter).
- With the inbox off, phone and email are disabled: they ride on it.
- When email isn't usable, one line says why. With no address it links
  to the profile. With no SMTP an admin gets a link to Integrations and a
  listener is simply told. Saving the profile refreshes the settings so
  the line clears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:25:31 -04:00
bvandeusenandClaude Opus 5.5 956058d4a0 feat(web): notifications bell, unread badge and inbox panel in the header (#5342)
- The bell sits between search and the user menu. Its badge is
  parchment on obsidian, not the accent, which the house style keeps
  off general chrome. It counts up to 9, then shows 9+.
- The panel lists the server-rendered title, body and relative time,
  newest first, with unread rows marked. Clicking a row marks it read
  and opens its link. "Mark all read" appears while anything is unread,
  and an empty inbox says "Nothing waiting for you."
- createNotificationsQuery and createUnreadCountQuery poll every 60s
  while the tab is visible. The `notification.created` live event
  invalidates ['notifications'] so the badge and list refresh promptly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:23:32 -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 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 43c369f082 test(android): queue drag math is a pure function, held to web's cases (#2436)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Successful in 4m46s
release / Build signed APK (releases and dev) (push) Successful in 4m58s
release / integration (push) Successful in 15m34s
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 drag-offset-to-row arithmetic lived inline in queueReorderDrag's
onDragEnd lambda, which no JVM test can reach. Web's copy, offsetToDelta,
has been extracted and tested since the start, and the Android comment
says it mirrors web, but only one side could be held to that.

- QueueDragMath.kt adds queueDragDelta (web's offsetToDelta) and
  queueDragTarget (delta plus the clamp to the queue). Kotlin's roundToInt
  breaks ties toward positive infinity, the same as JS Math.round, so the
  web cases carry over exactly, including half a row up staying put.
- queueDragTarget returns the start index for an empty queue instead of
  letting coerceIn(0, -1) throw.
- QueueDragMathTest mirrors queue-row-math.test.ts one case at a time,
  plus clamping past either end, a sub-half-row drag, an unmeasured row,
  and the empty queue.

No behaviour change for a non-empty queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:18:43 -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 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 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