The new codegen check failed on its first run, against the slice-1 hand-edit,
which is precisely why it landed on its own commit.
What I got wrong: sqlc does not embed the leading `--` header block in the SQL
const. It strips those lines and promotes them to the generated method's Go doc
comment, gofmt-formatted — blank `//` separators around the indented list, tabs
for the indent. My hand-edit left the header inside the string AND left the
stale M5c doc comment sitting on the function, so the generated file described
behaviour the query no longer had.
Comments *inside* the statement body are kept as-is; only the header block moves.
Worth knowing before slices 5 and 6 add more queries.
Taken verbatim from the diff the check printed, which is the reason it prints
before asserting. Round-trip cost: one CI run, no guessing.
Note the integration lane passed on the previous push even with the wrong
generated file — the SQL text was valid and the signature was unchanged, so
executing it against real Postgres proved nothing about whether the committed
Go matched its source. That gap is exactly what #2380 closes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
internal/db/dbq is 39 files and ~12k lines of generated Go covering 307
queries, and nothing checked that it still matched internal/db/queries.
test-go.yml referenced sqlc.yaml only as a path trigger; sqlc never ran. So a
hand-edit, a half-applied regen, or a migration changed without a regen would
all pass CI while the typed layer quietly lied about the SQL underneath it —
which is the single thing adopting sqlc is supposed to buy.
This session's slice-1 change is an instance: its SQL const was verified
byte-identical against its own .sql source by script, but never against what
sqlc would actually emit. Nothing in the repo could have told the difference.
make verify-generate runs ahead of vet/lint/test, because if the typed layer
disagrees with its sources then everything downstream is testing a lie.
generate-go runs sqlc as a Go tool rather than a container: the ci-go image
already has Go, so this avoids docker-in-docker on the runner. It's pinned to
the same SQLC_VERSION as the existing containerised `generate`, so both routes
emit identical output and there is one version to bump — now annotated for
Renovate per rule #44.
The diff prints BEFORE the exit-code check on purpose. On failure the log then
holds sqlc's exact expected output, so correcting it is a copy rather than a
guess. That is also what makes new queries workable without installing
anything: this workstation has neither Go nor sqlc.
Makefile joins the workflow's paths:. Without it a Makefile-only change —
including this one — would not trigger the workflow that now depends on it.
Same class as #2204, where CI never ran on plugin/** changes.
Landing this on its own, ahead of slice 3, so that if it fails it is
unambiguous whether the drift came from slice 1 or from new code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second half of the reported symptom: suggestions "show the same artists until
you request one". The ranking was `ORDER BY total_score DESC` with no
randomization and no seen-state, so the only things that could ever change the
deck were a candidate entering the library or the user filing a request. The
tail of the ranking was unreachable — requesting was literally the only lever.
No SQL change was needed. The query already takes a limit, so it over-fetches a
pool (4x the slots, capped at 60) and the selection moves to Go, where it is a
pure function of (pool, limit, day) — no DB, no clock — and therefore unit
testable in the fast lane instead of behind the integration gate.
Three rules. The best few by score always lead, so the strongest matches never
rotate out of sight (For You's head/tail shape). The remaining slots are drawn
by md5(mbid + day), the same daily-stable idiom the Home rows already use:
stable within a day so pull-to-refresh doesn't reshuffle, different tomorrow,
and no stored state. And a per-seed cap keeps roughly a quarter of the deck
attributable to any one seed artist, so twelve neighbours of a single artist
can't be the whole surface.
The cap is a preference, not a quota. A user whose pool hangs off one or two
seeds would otherwise get a three-card surface — worse than the monoculture
being avoided, and exactly the vanish-or-nothing shape rule #131 exists to
prevent — so a short deck tops up in score order from what the cap set aside.
This is also what keeps the existing Top12Cap integration test honest: its
30 candidates share one seed, and without the top-up it would return 3.
Eight unit tests, including one that had to be rewritten mid-change: the first
version asserted the cap against an evenly-spread pool, where the top-N is
already diverse and the assertion could not fail. It now uses a skewed pool
where one seed owns the entire top of the ranking, which is the only shape that
actually exercises a cap.
Dropped two //nolint:gosec directives added in passing — gosec isn't in
.golangci.yml, so they suppressed nothing and only implied a check that runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Discover request surface was the one recommendation surface still on its
M5c implementation from early May. #796's taste profile, #1488's taste_unheard
bucket and #1490's folksonomy enrichment all modernized in-library surfaces;
this one was never in scope for any of them, so it still projected raw likes +
plays through artist_similarity_unmatched.
Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed
over every play of the artist.
It is unbounded, and contribution is signal × similarity — so a handful of
heavily-played artists monopolize all twelve slots, and their share GROWS the
more the user listens. The surface entrenched harder the better it knew you,
which is exactly backwards and matches the reported "goes stale once it has a
strong signal of your taste".
It also counted every play_event with no was_skipped filter, so skipping an
artist repeatedly INCREASED its signal and pushed more of its neighbours at the
user. ListMostPlayedTracksForUser and the taste engine both filter skips; this
query was the odd one out.
Seeds now come from taste_profile_artists.weight, which the taste engine has
already engagement-graded, time-decayed and signed — an artist the user drifted
away from stops contributing instead of accumulating forever, and can even
contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is
the profile, tier 2 is likes + completed plays for a user who has no profile
rows yet (new account, or before the first daily recompute), so the surface
never empties. The old unfiltered-play signal is gone, not kept behind a toggle.
The signal is also log-damped, so one artist cannot take every slot even when
its weight dwarfs the rest.
$2 stays wired to the tier-2 decay: it is genuinely still used there, and
dropping the parameter would have changed the generated signature.
sqlc's image is not on this workstation and the change preserves the query
signature exactly — same three params, same seven columns — so only the
embedded SQL const moves. Both copies are edited and verified byte-identical
rather than pulling a container onto the operator's machine; a malformed query
fails the integration lane loudly, which is the real check either way.
Four integration tests cover what changed: a taste weight alone seeds with no
like or play; tier 2 does not run alongside tier 1; a non-positive weight never
seeds (guarded by a second positive row, so an empty tier 1 can't make it pass
for the wrong reason); and skip-only history seeds nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`refreshDetail` did an un-transacted `deleteByPlaylist` + `upsertAll` — the
same shape as the Home index write that #2327 just fixed. Room's
InvalidationTracker fires after the DELETE, so an observer of
`observeByPlaylist` would see `emptyList()` before the new rows land, which is
exactly what made every Home row visibly collapse to empty and refill.
Nothing consumes `observeByPlaylist` today, so this is not a live defect — it's
a landmine. Making playlist detail cache-first later would have silently
reintroduced the flicker, and the reason would have been three layers away from
the symptom. One `@Transaction` now costs nothing and removes that.
`deleteByPlaylist` is left in place as the building block but is no longer
called from outside the DAO.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sheet still described the pre-M8 world: "two CI images: ci-go +
ci-flutter", a ci-flutter dep list, and cross-workflow release polling
against flutter.yml. None of that is true now — flutter.yml is gone,
android.yml and release.yml both pull ci-android:36, and image-release
gates on `needs: [android-release]` instead of polling.
Family rule 39 makes this sheet CI-Runner's decision input for "add a dep
to an image vs. fork a variant", so a stale sheet quietly misinforms that
call: CI-Runner was still carrying ci-flutter for a consumer that no
longer exists, and had no record of ci-android's real consumer.
- Runtime images: ci-flutter:3.44 -> ci-android:36, with a note on why
ci-flutter is now unconsumed and what would have to change to revive it.
- Image deps: replace the Flutter/Dart/NDK list with the actual
ci-android surface (JDK 25 + Gradle 9.1 floor, SDK/build-tools 36, no
NDK, ktlint + detekt).
- Label/image split: record that Android jobs still schedule on the
flutter-ci label on purpose — it's a scheduling handle, not a toolchain
assertion.
- Update channel: `needs:` gating, plus the non-tag rebundle path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The veil raised eagerly: any trigger over a warm cache put it up before
knowing whether the refresh would change anything. So every launch cost
~1-2s of opaque panel even when the pull returned exactly what was already
cached — which, now that the section swap is atomic and the index flow dedups
on ids, produces no visible churn to hide at all. The veil was covering
nothing and only delaying first paint.
The raise is now reactive: it fires when the content key actually differs from
what was already on screen, and never for a no-op refresh. The baseline is the
first state that HAS content, not the first state at all — over a warm cache
the cached rows paint a moment after the session starts, and counting that
first paint as "a change" would veil every launch, which is the thing being
fixed. Cost of reacting rather than anticipating: the veil arrives one emission
after the change, so a single atomic swap shows through. Everything messier
that follows it — tile hydration, then artwork — still lands behind it.
That leaves a hole this closes too: a manual pull where nothing changed would
now produce no veil, no movement, nothing whatsoever, which reads as broken. So
sessions report an outcome — CHANGED / UNCHANGED / FAILED — and Home surfaces
it as "Already up to date" or "Couldn't check for updates".
Only for refreshes a person actually asked for. "Already up to date" on every
launch, every 03:00 rebuild and every reconnect would be worse than silence, so
VeilSessionResult carries a userInitiated bit and background sessions stay
quiet. The bit is tracked separately from the request token because the request
channel is CONFLATED: coalescing drops the older token, and a user's pull must
not be swallowed by a background trigger arriving on its heels.
The surfaced failure is a deliberate narrowing of the earlier "silent on give
up" call, which is now read as being about background refreshes: for a pull the
user deliberately triggered, silence looks broken, and staying silent while the
success case speaks would be incoherent. Recovery is unaffected either way.
Pull-to-refresh now waits for whichever successor actually arrives — the veil,
or the snackbar — via finishedSessions, instead of only ever waiting on the
veil and timing out for 2s on an unchanged pull.
The Error-state Retry goes through the controller as well, so it gets the
retries and reports its outcome; over an empty cache there's no content to
protect, so no veil appears. HomeViewModel.refresh() is gone, replaced by
retry() and refreshFromPull() — the two things that actually exist.
Tests: two changed meaning and are rewritten rather than patched. A failed pull
writes nothing, so the veil no longer stands over the retries — it goes up when
a retry finally lands. And "waits for content to paint" became "cached content
painting is not mistaken for a change", which is the baseline subtlety above.
Added coverage for UNCHANGED, FAILED, the cold-load CHANGED case, and the
conflation of a user request with a background one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All seven new UpdateVeilController tests failed in CI run 3163, and the
one test that passed is the tell: it was the only one that never called
advanceUntilIdle().
advanceUntilIdle() advances only while *foreground* work remains. Every
coroutine this controller owns lives in backgroundScope — it has to, because
its consumer loop runs forever and would otherwise stop runTest from
completing — so advanceUntilIdle() returned having run nothing at all, and
the assertions landed on a session that never started. Hence "exhausts its
attempts. Expected <3>, actual <0>" and, where an earlier advanceTimeBy had
got a session partway, "retries until the pull succeeds. Expected <3>,
actual <2>".
Each wait is now an explicit advanceTimeBy sized for what that test still
has pending, and the class KDoc says why so nobody folds them back.
The drains stay deliberately under maxHoldMs. If a drain overshot the
ceiling, "the veil lowered" would stop distinguishing "it settled" from "it
gave up" — which is exactly what these tests exist to tell apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI run 3161 reported seven failures as bare "java.lang.AssertionError at
UpdateVeilControllerTest.kt:87" — and line 87 is the test's own `fun ... =
runTest {` line, not the assertion. Gradle picks the first stack frame
belonging to the test class, and assertions inside a `runTest { }` lambda
live in a generated suspend-lambda class that gets filtered out, so every
failure in a coroutine test collapses to the function declaration. With the
HTML report unreachable from CI, that leaves nothing to debug from.
testLogging with exceptionFormat = FULL prints the assertion message and the
whole stack trace for failures, which is what makes a coroutine-test failure
diagnosable at all here.
Also drop the NonCancellable floor-join from UpdateVeilController's finally.
Honouring the minimum hold while the scope is being torn down is pointless —
nothing is left to render the veil — and a finally that suspends is a finally
that can resist cancellation. The floor is now awaited in the try instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Updating your mixes…" veil wiped on and straight back off before the
update finished, and a number of churn paths never raised it at all.
Three reasons it lowered early. refreshBehindVeil held it for
refresh().join() + a flat 500ms, but finishing the network pull is nowhere
near the end of the visible work: refreshIndex writes only the section id
lists, then each tile hydrates through MetadataProvider (null → skeleton →
album), and only then does the cover art load. Second, updatingInternal was
a plain Boolean cleared in a finally — reconnect and playlist.system_rebuilt
routinely arrive together, so whichever pull finished first wiped the veil
off while the other was still running. Third, refresh() swallowed every
failure in runCatching, so join() returned "fine" after a failed pull: veil
off, content unchanged, no retry.
So the veil's lifetime is now driven by watching the screen instead of by a
guess. UpdateVeilController raises, runs the work (retrying behind the veil),
then holds until the content signature has been unchanged for a quiet window
AND nothing is still loading — floored by a minimum hold so it cannot flash,
capped by a hard ceiling so it cannot strand, and with overlapping triggers
folded into one session rather than racing it. Giving up is silent and sets
no latch: the reconnect-driven recovery and the freshness sweeper keep
retrying afterwards exactly as before.
Cover art was the most visible pop-in and the refresh coroutine cannot see
it, so the composition reports it upward: ServerImage — the single choke
point behind CoverTile for every album/artist/playlist cover — counts its
in-flight loads into an ArtSettleTracker the veil waits on. Art also
crossfades now (set once on the ImageLoader, so it applies app-wide) with
the placeholder fading out over the same window, which softens the pop
everywhere the veil isn't involved.
Underneath all of it, the churn is largely no longer generated. replaceSection
was delete-then-insert per section, un-transacted, so observeBySection emitted
emptyList() — a visible collapse — before refilling, seven times in sequence.
It is now one @Transaction across all sections (Room notifies once, on commit,
so the empty gap is never observed), and the index flow dedups on the id list,
so a section whose contents did not move no longer tears down and rebuilds
every tile's hydration flow. fetchedAt is restamped on every write, which is
why the dedup compares ids rather than rows. Same fix CachedQuarantineDao
already carried for the same reason.
Trigger set widened per the operator's call: the initial load over a warm
cache (a full re-pull that churned every section completely unveiled), manual
pull-to-refresh, scan.run_finished (Home never reacted to it at all), and the
playlist.created/updated/deleted/tracks_changed kinds. The veil waits for
content to be on screen before raising, so a genuinely cold load still gets
its skeleton rather than an opaque panel over nothing.
refreshError is now cleared on success rather than at the start of each
attempt — with retries, clearing it up front made a failing cold start flash
the "Welcome to Minstrel" empty state between attempts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous pin matched the two actions by their own version numbers, which
is meaningless: upload-artifact and download-artifact release on unrelated
cadences. upload v5 bundles @actions/artifact ^4.0.0; download v5 bundles
^2.3.2. "v5 and v5" was in fact a mismatched pair.
download v6 is the tag that puts ^4.0.0 on both sides — and ^4.0.0 is the
library major just proven against this instance by the upload side
(thoughtsync run 3094: two artifacts listed, downloaded and extracted
intact). ^2.3.2 has never been exercised here.
Not v7: that major is a runner requirement rather than a feature change. It
moves to runs.using: node24 and upstream requires runner >= 2.327.1 for it,
which act_runner does not claim to satisfy. Everything pinned stays node20.
ci-requirements.md now carries the version/runtime table and the reasoning,
so the next person matches on the library instead of the tag number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
android.yml and release.yml uploaded via actions/upload-artifact@v3, which
reports success while Gitea stores the result in a format its v4-only
artifact API will never serve back — 72 artifacts on this repo are on disk,
have valid DB rows, and are invisible to every retrieval path. Green jobs
producing nothing retrievable.
release.yml is a producer/consumer pair: android-release uploads
minstrel-apk and image-release downloads it to bundle into the container.
Swapping only the upload would have left download-artifact@v3 reading the
v1/v3 listing and finding nothing, so mirror the download side too —
bvandeusen/download-artifact, pull mirror of forgejo/download-artifact,
pinned at its v5 tag to match the upload pin's major.
Not actions/{upload,download}-artifact@v4: isGhes() throws on the hostname
before opening a connection, so no server-side change reaches it.
Upload steps also set if-no-files-found: error — image-release hard-depends
on minstrel-apk existing, so an empty upload must fail where it happens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding the queue move/remove/clear pass-throughs pushed the VM to 12 functions
(detekt cap 11). It's a thin transport facade forwarding to PlayerController, so
suppress with a rationale rather than splitting the delegating surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Queue screen gains: album-art thumbnails (ServerImage), drag-to-reorder via a
grip handle (offset->delta on release, mirroring the web), a remove button per
row, auto-follow of the now-playing track with a 'Jump to current' pill when
scrolled away, a clear-queue action, and a header count + total-time summary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds moveInQueue/removeFromQueue/clearQueue, each keeping the domain queueRefs
snapshot in lock-step with the Media3 timeline (mirrors playNext/enqueue). Media3
onEvents rebuilds uiState so the queue view reflects reorder/removal/clear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QueueList now follows the now-playing row as the track auto-advances (only
while it's in view), centers it on open, and surfaces a 'Jump to current' pill
once the user scrolls it off-screen. Header gains a clear-queue action backed
by a new store clearQueue() that empties the queue and stops playback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a 40px cover thumbnail (coverUrl(album_id), FALLBACK_COVER on error) to
each queue row, matching the artwork every comparable player shows in its
up-next list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The queue auto-scroll $effect calls scrollIntoView on render, and jsdom
doesn't implement it, so QueueDrawer.test.ts threw an unhandled TypeError that
failed the run even though every assertion passed. Polyfill it as a no-op in
the shared setup; tests never assert on scroll position.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QueueList gains an `active` prop; when it flips true (drawer opens) or on mount
(now-playing panel) it centers the current row in view. Index/length are read
untracked so it positions once per open rather than following auto-advance,
matching the Android queue. QueueDrawer passes active={queueDrawerOpen} since
its aside is always mounted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QueueList used a plain LazyColumn with no hoisted state, so the queue always
opened at the top and the current track could be off-screen. Seed a
rememberLazyListState with the current index (coerced into bounds) so the list
renders already positioned on the now-playing row — no post-layout scroll flash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The queue drawer's <aside> is always mounted, so QueueTrackRow's LikeButton
(added in #1596) instantiates the moment the queue populates on first play.
LikeButton calls useQueryClient() at init; with the drawer outside the
provider it threw 'No QueryClient was found in Svelte context', aborting the
reactive flush that starts playback — so play appeared to do nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Device verification of v2026.07.15 found the notification/lock-screen
next+prev buttons dead during a UPnP cast (play/pause worked). Root cause:
the system media controls issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
-> Player.seekToNext()/seekToPrevious(), which are DISTINCT from the
seekToNextMediaItem()/seekToPreviousMediaItem() the in-app buttons call and
which MinstrelForwardingPlayer already routes to Sonos. seekToNext/Previous
were un-overridden, so ForwardingPlayer forwarded them to the paused local
delegate — nudging its cursor, which the identity poll then re-synced back to
Sonos, so the buttons read as dead.
Override seekToNext()/seekToPrevious() to delegate to the media-item variants
(the full Sonos path: optimistic local advance + AVTransport Next/Previous +
pending-transport gate) when a UPnP route is engaged; plain local playback
keeps the default behaviour. Fixes notification/lock-screen/Auto/Wear skip
during a cast. Completes milestone #171 Step 3 (#1606 / #606).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The local ExoPlayer cursor and the Sonos renderer were two competing
sources of truth for "what's playing" during a cast. The delegate cursor
lagged (forward-only, index-based, size-capped sync, skipped during every
load/re-cast window), and TWO writers of PlayerUiState.queueIndex fought:
PlayerController.onEvents (reading the lagging cursor) stomped the
Sonos-derived index the position tick published, so the in-app player
flickered to the pre-cast track and the notification metadata went stale.
Step 1 — MinstrelForwardingPlayer.syncLocalCursorToRemote (replaces
maybeSyncLocalCursor): align the paused delegate cursor to the track the
renderer is actually playing, matched by track-id parsed from the Sonos
TrackURI (/api/tracks/{id}/stream) against delegate MediaItem.mediaId
(== TrackRef.id). Both directions; survives queue-reload index wobble;
nearest-occurrence tiebreak for duplicate tracks; falls back to the Sonos
Track index; suppressed during load and while a user transport is pending
Sonos's ack. The cursor is now the single authoritative "current track"
that both the in-app UI (onEvents) and the notification (getCurrentMediaItem)
read.
Step 2 — PlayerController: the position tick now patches only
position/duration/play-pause/buffer; onEvents is the sole writer of
queueIndex/currentTrack. Removed desiredQueueIndex, the forward-only
trackChanged path, and publishTickIfChanged. One writer, no stomp.
Part of milestone #171 (unify local + UPnP behind one cursor). Fixes the
flicker + stale-notification symptoms; supersedes #1211/#608/#612.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior test fix registered the emptyLikesMock stub but imported it
(and the component under test) in the wrong order: importing QueueTrackRow
/ QueueDrawer transitively loads LikeButton → the mocked $lib/api/likes,
whose hoisted factory runs before the emptyLikesMock import initialized —
"Cannot access '__vi_import_N__' before initialization".
Move the emptyLikesMock import above, and the component import below, the
vi.mock call — matching the ArtistMenu/PlayerBar test layout so the
factory's binding is ready when the component graph loads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery
and needs a QueryClient in Svelte context. The QueueTrackRow / QueueDrawer
unit tests render the rows without one, so they failed with "No
QueryClient was found in Svelte context". Mock $lib/api/likes with the
shared emptyLikesMock() helper — the same pattern PlayerBar/TrackMenu and
17 other component tests already use.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full-screen player's queue ("up next") track rows were the one
track-list surface missing the like heart that TrackRow/PlaylistTrackRow
(web) and playlist/album/artist detail (Android) already carried.
Web: render the shared <LikeButton> in QueueTrackRow between the row body
and the remove button (serves both the /now-playing aside and the mobile
QueueDrawer, same component). LikeButton already stops click propagation
so it won't trigger play-on-click.
Android: PlayerViewModel now exposes likedTrackIds (set-based, the same
idiom as the detail VMs) + toggleLikeTrack; QueueScreen threads
liked/onToggleLike through QueueList → QueueRow, which renders the shared
LikeButton after the duration. Liked state stays sourced from
LikesRepository by track.id — no TrackRef data-model change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Opt 3b. Adds device class as a third context axis on top
of the #1531 time-of-day/weekday affinity: on the radio path, a candidate
is boosted when its artist concentrates in the current (daypart × weekday
× device) cell. Client-sent (client_id is opaque; no UA stored), so it's
captured going forward and applies to radio only (daily mixes are
cron-built with no device → stay device-agnostic).
Server:
- Migration 0048: play_events.device_class text NULL (no CHECK; normalized
in Go — one whitelist entry per new client class, not a migration).
- events.go: eventRequest.device_class + normalizeDeviceClass (whitelist →
mobile/web/…, else "other", empty → NULL); threaded through both
RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent.
- ListArtistContextPlayCountsForUser gains a current-device param; the cell
FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531
time-only behaviour exactly (used by mixes). SessionVector.DeviceClass
carries it; the radio handler derives the current device from the user's
latest play (GetLatestPlayDeviceClassForUser) — request-free proxy.
- No new tuning knob: device narrows the existing ContextAffinityScore
(reuses context_time_weight).
Clients:
- web: play_started sends device_class 'web'.
- android: play_started + offline replay send 'mobile' (EventsWire +
PlayOfflinePayload + MutationReplayer + PlayEventsReporter).
Test: LoadContextAffinity device-narrowing integration test (mobile vs web
artist separation; device-agnostic parity).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Milestone #160 Opt 5. A collaborative candidate arm: tracks by artists
co-played across the instance with the seed's artist.
Minstrel is a single shared-library, multi-user server (no per-user
library ACL — verified: no owner/share/group model), so the "household"
is the whole instance's user set; the rule #47 scoping is satisfied by
the shared-library boundary. Single-user servers produce no edges.
- No migration: source='user_cooccurrence' was pre-whitelisted in the
0009 similarity CHECK from day one.
- internal/db/queries/coplay.sql: Delete + Insert artist co-play edges.
Score = Jaccard of the two artists' distinct-player sets (controls for
globally-popular artists); >= 2 co-players AND Jaccard >= floor kept
(the floor also self-limits hub artists). Completed plays, 365d window.
- internal/coplay: periodic worker (6h) that atomic-replaces the
user_cooccurrence edge set from play_events — pure local SQL, no
external calls. Wired in main.go alongside the similarity worker.
- LoadRadioCandidatesV2: new coplay_artists arm (source='user_cooccurrence',
seed-artist based, 0.5 damp like similar_artists) + $11 limit;
CandidateSourceLimits.UserCoplay (default 20, For-You 40).
- Integration tests: perfect-overlap Jaccard=1.0 edge + single-user
empty-set gate.
Device axis and AcousticBrainz (Opt 4) are separately tracked; this
closes the milestone-#160 sequential options.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Bring the tag-sources settings surface to the Android admin, over
/api/admin/tag-sources — the operator can enable/disable each provider,
paste an API key (e.g. Last.fm), and test the connection from the phone,
mirroring the web integrations card.
New vertical stack (mirrors the AdminUsers/AdminRequests pattern):
- AdminTagSourcesApi (Retrofit, api/admin/tag-sources GET/PATCH/{id}/test)
+ UpdateTagSourceBody
- AdminTagSourceWire / list envelope / TestTagSourceWire + domain
AdminTagSourceRef / TagSourceTestResult
- AdminTagSourcesRepository (shared Retrofit, .toDomain() at bottom)
- AdminTagSourcesViewModel (@HiltViewModel, sealed UiState, optimistic
toggle + key-save + per-row test result, network auto-recovery)
- AdminTagSourcesScreen (MinstrelTopAppBar + PullToRefreshScaffold; per
provider: Switch, password key field + Save, Test connection + result)
- nav route + graph registration; AdminLanding gains a "Tag sources"
section card (count = enabled providers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Widen keyless coverage: when a track's recording is untagged or has no
recording MBID, fall back to the artist's tags (/ws/2/artist/{mbid}
?inc=tags), down-weighted 0.6 as a coarser signal. Recording tags still
win outright when present.
- ListTracksMissingTags returns a.mbid AS artist_mbid; TrackRef gains
ArtistMBID; the enricher threads it through.
- MB provider: recording-first, artist-fallback via a shared
fetchEntityTags helper. A transient error at the recording step is
returned (retry) rather than masked by the fallback.
Enricher, registry, and settings are untouched — the pluggable design
absorbs the wider lookup. Tests cover fallback-when-untagged,
artist-only-when-no-recording-MBID, and recording-preferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Add tag_provider_settings to the truncation list and reset
tag_sources_meta.current_version to 1 in ResetDB, mirroring the cover-art
settings reset. Without it the migration-seeded musicbrainz/lastfm rows
leaked across tests and desynced the enabled-set signature, so a key-only
PATCH spuriously reported version_bumped=true
(TestAdminUpdateTagSource_KeyOnlyDoesNotBump).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose the tag-enrichment provider settings over the admin API, mirroring
the cover-sources surface so a Last.fm key can be pasted and sources
toggled from the web admin UI (rules #25/#27).
- GET /api/admin/tag-sources — list providers + version
- PATCH/api/admin/tag-sources/{id} — enable / set api_key
- POST /api/admin/tag-sources/{id}/test — test connection
- POST /api/admin/tag-sources/research — bump version, re-open
settled rows for re-enrich
- Thread tags.SettingsService through server.New (struct field, like
RecSettings) → Router → api.Mount → handlers.tagSettings; main.go sets
srv.TagSettings.
Handler tests mirror admin_cover_sources_test (list / flip-bumps-version /
key-only-no-bump / unknown-404 / non-admin-403 / not-testable-ok-false),
integration-tier (skip without MINSTREL_TEST_DATABASE_URL).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The taste recompute's tag facet now unions the cached track_tags
(MusicBrainz/Last.fm folksonomy tags) alongside raw ID3 genre, so a coarse
"Rock" gains "post-punk / shoegaze / melancholic".
- taste_profile.sql: ListPlayEngagementInputsForUser +
ListLikedTrackTasteInputsForUser now return track_id to key the
enriched-tag lookup.
- accumulate(): for each play, fold its track's enriched tags weighted by
engagement × tag.weight × EnrichedTagScale; for each liked track, by the
tag-like bonus × tag.weight × scale. A track with no cached tags
contributes genre only (graceful).
- New Config.EnrichedTagScale (default 0.5) — enriched tags augment the
ID3 signal without swamping it; 0 = genre-only. Flows through
recsettings.TasteConfig() (starts from DefaultConfig). Promoting it into
the admin tuning lab is a small follow-up.
Unit-tested the pure foldEnrichedTags helper (overlap accumulation +
scale=0 disable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Construct the tag SettingsService + Enricher at boot (mirroring coverart:
reconcile providers, bump the sources version if the provider set changed
to re-open settled rows), then run a standalone background Worker that
drains tracks needing folksonomy tags on a periodic tick.
Standalone (not threaded through the file-scan chain like cover art)
because tag lookups need only DB fields — recording MBID / artist / title
— so it mirrors the ListenBrainz similarity worker instead: an initial
drain shortly after boot, then every 30 min, up to 200 tracks per tick.
MusicBrainz's 1 req/s ceiling is the real throttle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Milestone #160 Option 1, Step 2. New internal/tags package that enriches
the taste profile's tag facet beyond raw ID3 genre, built so adding a
source later is "implement TrackTagProvider + Register()" — no enricher,
settings, or schema change (operator directive).
Mirrors the internal/coverart pattern:
- Provider interface + package registry (Register/AllProviders/ByID);
TrackTagProvider fetch capability + TestableProvider for the admin test.
- DB-backed SettingsService over new tag_provider_settings +
tag_sources_meta (migration 0043) — enable/key/version, boot
reconciliation, and a provider-hash bump that re-opens 'none' rows when
the compiled-in provider set changes.
- Slim self-contained httpClient (rate-limit + retry + User-Agent), kept
local so tag enrichment never depends on coverart internals.
Providers:
- MusicBrainz: keyless, default-ON, recording tags by MBID (rule #26 baseline).
- Last.fm: keyed, default-OFF, track.getTopTags by artist+track — opt-in
once a key is supplied.
Enricher uses MERGE semantics (differs from coverart's first-success-wins
for a single image): unions tags across every enabled provider, caps to
top-K by weight, and stamps tag_source musicbrainz|lastfm|mixed|none.
Writes are transactional (atomic replace of track_tags).
Unit-tested without a DB: registry mechanics, provider JSON parsing +
weight normalization via httptest, and the pure merge/top-K/source-label
helpers. Wiring (startup + on-scan), integration tests, and the taste
union (Step 3) + Settings UI (Step 4) come next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The play button overlaid on artist circles sat under the circular frame:
CoverTile clipped the Box that held both the artwork and the overlay, so
a BottomEnd button on a CircleShape avatar — which falls in the square's
corner, outside the circle — got clipped away.
Draw the overlay on an outer un-clipped Box; clip only the inner artwork
+ background to `shape`. Corner-anchored overlays (play button, variant
pill) now sit on top of the frame. Bounds/alignment unchanged, so Album
and Playlist tiles keep their layout (pill is padding-inset; their play
buttons are simply no longer clipped at the corner radius).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trailing-lambda assertTrue overload treats the block as the
*condition*, not a lazy message — switch to assertTrue(Boolean, String).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the three early returns into a single `when` expression —
detekt's ReturnCount capped at 2. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.
Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
and daily rotation preserved.
Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
/ orderedRealPlaylists no longer reserve the 3 songs-like slots.
Offline shows cached mixes (available-first), hides the row when none.
Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
and removed songs-like from the Playlists carousel.
Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>