Commit Graph
1803 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 8483948f23 docs(ci): true up ci-requirements.md — ci-android replaced ci-flutter
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>
2026-08-01 10:38:50 -04:00
bvandeusen 0cea82984c Merge pull request 'Home updating-veil rework: change-triggered, settle-driven, with refresh feedback' (#114) from dev into main
android / Build + lint + test (push) Successful in 4m42s
release / Build signed APK (tag releases only) (push) Successful in 4m34s
release / Build + push container image (push) Successful in 1m37s
v2026.08.01
2026-07-31 23:32:05 -04:00
bvandeusenandClaude Opus 5 3acac985cd feat(home): veil only when content changed; tell the user when it didn't — #2327
android / Build + lint + test (push) Successful in 4m8s
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>
2026-07-31 23:16:49 -04:00
bvandeusenandClaude Opus 5 d3b40342b4 test(home): drive the veil tests' clock explicitly, not advanceUntilIdle
android / Build + lint + test (push) Successful in 3m54s
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>
2026-07-31 20:47:20 -04:00
bvandeusenandClaude Opus 5 4f99b42844 ci(android): print full assertion messages for failing tests
android / Build + lint + test (push) Failing after 3m11s
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>
2026-07-31 20:39:54 -04:00
bvandeusenandClaude Opus 5 5044e7a055 fix(home): hold the updating veil until Home actually settles — #2327
android / Build + lint + test (push) Failing after 3m7s
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>
2026-07-31 20:31:30 -04:00
bvandeusen 7d45a4e5c7 ci: artifacts that can actually be downloaded (issue 2270)
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m10s
android / Build + lint + test (push) Successful in 4m29s
2026-07-30 15:51:15 -04:00
bvandeusenandClaude Opus 5 fa0827f668 ci: pin the download mirror to v6, not v5 — match on @actions/artifact
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>
2026-07-30 15:37:43 -04:00
bvandeusenandClaude Opus 5 52d53e0044 ci: swap artifact upload+download to the mirrored actions (issue 2270)
android / Build + lint + test (push) Successful in 4m30s
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>
2026-07-30 15:28:10 -04:00
bvandeusen a26ef4e93c Merge pull request 'Queue fix + cross-client queue enhancements' (#112) from dev into main
test-web / test (push) Successful in 1m10s
android / Build + lint + test (push) Successful in 4m59s
release / Build signed APK (tag releases only) (push) Successful in 4m3s
release / Build + push container image (push) Successful in 1m42s
v2026.07.23
2026-07-23 08:31:34 -04:00
bvandeusenandClaude Opus 4.8 0774f5f55f fix(player): suppress TooManyFunctions on PlayerViewModel facade — #1944
android / Build + lint + test (push) Successful in 3m43s
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>
2026-07-22 23:16:17 -04:00
bvandeusenandClaude Opus 4.8 509cbe79b2 feat(player): Android queue — reorder, remove, art, auto-follow, clear — #1944
android / Build + lint + test (push) Failing after 1m26s
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>
2026-07-22 23:12:23 -04:00
bvandeusenandClaude Opus 4.8 dc7b9b78fa feat(player): queue move/remove/clear on PlayerController + VM — #1944
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>
2026-07-22 23:12:23 -04:00
bvandeusenandClaude Opus 4.8 cde74b5965 feat(player): web queue auto-follow + jump-to-current pill + clear-queue — #1944
test-web / test (push) Successful in 40s
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>
2026-07-22 22:57:23 -04:00
bvandeusenandClaude Opus 4.8 0efbf5fcaa feat(player): album-art thumbnails in web queue rows — #1944
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>
2026-07-22 22:57:23 -04:00
bvandeusenandClaude Opus 4.8 2038028d42 test(web): no-op scrollIntoView in vitest setup (jsdom lacks it) — #1931
test-web / test (push) Successful in 33s
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>
2026-07-22 21:27:35 -04:00
bvandeusenandClaude Opus 4.8 723293110d feat(player): scroll web queue to now-playing track on open (Android parity) — #1931
test-web / test (push) Failing after 38s
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>
2026-07-22 21:24:29 -04:00
bvandeusenandClaude Opus 4.8 41ebf1405b fix(player): open Android queue scrolled to now-playing track — #1929
android / Build + lint + test (push) Successful in 4m8s
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>
2026-07-22 21:11:32 -04:00
bvandeusenandClaude Opus 4.8 f2dcf2596d fix(player): render QueueDrawer inside QueryClientProvider so queue LikeButtons resolve — #1928
test-web / test (push) Successful in 40s
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>
2026-07-22 20:47:21 -04:00
bvandeusen 47de7be472 fix(player): route notification next/prev to Sonos while casting — #171 (#111)
android / Build + lint + test (push) Successful in 5m51s
release / Build signed APK (tag releases only) (push) Successful in 5m27s
release / Build + push container image (push) Successful in 1m47s
v2026.07.15
2026-07-15 19:17:11 -04:00
bvandeusenandClaude Opus 4.8 659554df0e fix(player): route notification next/prev to Sonos while casting — #171
android / Build + lint + test (push) Successful in 4m9s
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>
2026-07-15 19:11:40 -04:00
bvandeusen 2ecdd46a2b Player: unify local+UPnP behind one cursor (#171) + queue heart button (#1596) (#110)
release / Build + push container image (push) Successful in 17s
test-web / test (push) Successful in 51s
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 5m17s
2026-07-15 16:30:52 -04:00
bvandeusenandClaude Opus 4.8 41fe76b90c fix(player): identity-locked UPnP cursor + single index writer — #171
android / Build + lint + test (push) Successful in 4m23s
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>
2026-07-15 14:59:03 -04:00
bvandeusenandClaude Opus 4.8 6912dadf2b fix(test): order likes mock before component import in queue tests — #1596
test-web / test (push) Successful in 36s
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>
2026-07-15 13:12:37 -04:00
bvandeusenandClaude Opus 4.8 304e06acc8 test(player): stub likes API in queue component tests — #1596
test-web / test (push) Failing after 33s
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>
2026-07-15 13:08:06 -04:00
bvandeusenandClaude Opus 4.8 235839b696 feat(player): heart/like button in queue view (web + android) — #1596
test-web / test (push) Failing after 33s
android / Build + lint + test (push) Successful in 4m25s
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>
2026-07-15 13:02:32 -04:00
bvandeusen 4f69c230c4 Merge pull request 'Taste-profile fidelity (M160) + Songs-like row + home polish' (#109) from dev into main
test-go / test (push) Successful in 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Successful in 5m1s
android / Build + lint + test (push) Successful in 5m5s
release / Build signed APK (tag releases only) (push) Successful in 4m9s
release / Build + push container image (push) Successful in 19s
v2026.07.14
2026-07-14 13:03:12 -04:00
bvandeusenandClaude Opus 4.8 5749f48b4a feat(taste): device-class context conditioning — #1551
test-go / test (push) Successful in 45s
test-web / test (push) Successful in 52s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 5m5s
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>
2026-07-14 12:47:59 -04:00
bvandeusenandClaude Opus 4.8 f0c08e7326 feat(taste): mood taste facet — #1534
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m44s
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste
facet alongside artists + genre tags + eras: signed weights over canonical
mood buckets (melancholic / energetic / chill / …) derived from a track's
enriched folksonomy tags (#1490).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:01:00 -04:00
bvandeusenandClaude Opus 4.8 40056d2e9a feat(android): admin tag-enrichment sources screen (#1521)
android / Build + lint + test (push) Successful in 3m43s
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>
2026-07-14 08:16:43 -04:00
bvandeusenandClaude Opus 4.8 2b3be8311a feat(tuning): expose EnrichedTagScale in the tuning lab (#1520)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m45s
Promote the enriched-tag weight (#1490) from a taste.Config default into
the DB-backed tuning lab so operators can dial how much folksonomy tags
count vs raw ID3 genre (rule #25).

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:33:21 -04:00
bvandeusenandClaude Opus 4.8 96c2eb6afb fix(dbtest): reset tag-sources settings between tests (#1490)
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>
2026-07-13 22:32:51 -04:00
bvandeusenandClaude Opus 4.8 cce928e584 feat(api): admin tag-sources endpoints (#1490 Step 4 backend)
test-go / test (push) Successful in 29s
test-go / integration (push) Failing after 4m43s
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>
2026-07-13 22:24:28 -04:00
bvandeusenandClaude Opus 4.8 7d18a3c808 feat(taste): fold enriched folksonomy tags into the profile (#1490 Step 3)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m43s
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>
2026-07-13 22:09:31 -04:00
bvandeusenandClaude Opus 4.8 c34753f5b0 feat(tags): wire tag-enrichment worker at startup (#1490 wiring)
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>
2026-07-13 22:09:31 -04:00
bvandeusenandClaude Opus 4.8 fbfd5550ff feat(tags): folksonomy tag enricher — pluggable provider chain (#1490 Step 2)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m46s
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>
2026-07-13 21:43:58 -04:00
bvandeusenandClaude Opus 4.8 d6ee5a304d fix(home): un-clip CoverTile overlay so play button isn't cut off (#1495)
android / Build + lint + test (push) Successful in 3m58s
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>
2026-07-13 20:56:07 -04:00
bvandeusenandClaude Opus 4.8 d497c57d6c fix(home/test): kotlin.test assertTrue message overload
android / Build + lint + test (push) Successful in 3m46s
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>
2026-07-13 20:49:10 -04:00
bvandeusenandClaude Opus 4.8 2e92ba498c fix(home): buildSongsLikeRow ReturnCount ≤ 2 (detekt)
android / Build + lint + test (push) Failing after 3m4s
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>
2026-07-13 20:45:05 -04:00
bvandeusenandClaude Opus 4.8 c1d143cf4a feat(home): Songs-like → dedicated row + wider spread (#1491)
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 43s
android / Build + lint + test (push) Failing after 1m29s
test-go / integration (push) Successful in 4m42s
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>
2026-07-13 20:41:08 -04:00
bvandeusen cb0af5efd3 fix(dbq): commit the rest of the 0042 regen (Track model + embedders)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m37s
The previous commit staged only track_tags.sql.go and left the rest of
the sqlc regen uncommitted, so HEAD referenced TrackTag / the new
tracks.tag_source columns without their definitions — a broken tree.

Adding tracks.tag_source + tag_sources_version to the tracks table
regenerated every generated file that returns/embeds the Track model
(models.go, tracks/events/history/likes/recommendation). Commit them all
together so dev HEAD compiles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:47:34 -04:00
bvandeusen 20a76f4b39 feat(taste): track_tags schema + enrichment queries (Opt 1 foundation)
test-go / test (push) Failing after 10s
test-go / integration (push) Has been cancelled
First step of taste-profile fidelity via metadata enrichment (milestone
#160, task #1490) — no ML sidecar, operator's constraint.

The taste profile's tag facet is built purely from raw ID3 tracks.genre
(splitGenres in internal/taste/profile.go). This lands the data layer for
enriching it with track-level folksonomy tags:

- track_tags(track_id, tag, weight) — a global cache of style/mood tags,
  top-K per track, weight = normalized folksonomy strength [0,1].
- tracks.tag_source / tag_sources_version — versioned enrichment
  bookkeeping mirroring artists.artist_art_source (NULL = eligible,
  provider name = found, 'none' = settled, version bump = re-process).
- Queries: ListTracksMissingTags (batch drainer), DeleteTrackTags +
  InsertTrackTag (atomic per-track replace), SetTrackTagSource, and
  ListPlayed/LikedTrackTagsForUser for the recompute to union enriched
  tags into the tag facet alongside genre.

No consumer yet — the enricher (MusicBrainz + Last.fm providers,
track-level) and the taste-recompute integration land next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:46:49 -04:00
bvandeusen 7226dab9ff feat(discover): taste-targeted novelty bucket + rebalance toward discovery
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m38s
The 2-week metrics review found Discover beating the manual baseline on
skip rate — which for a discovery surface means it plays it safe. On a
single-user server it's effectively dormant + crude-random, and the
per-user taste profile (taste_profile_tags, #796) went unused (#1254 gap).

Add a fourth Discover candidate bucket, ListTasteUnheardTracksForDiscover:
unheard / non-liked / non-quarantined tracks ranked by summed taste-tag
weight over tracks.genre (split like the radio tag_overlap arm), md5
tiebreak. Its picks are stamped pick_kind = 'taste_unheard' (migration
0041 widens the CHECK on playlist_tracks + play_events, rule #36).

Rebalance the slot allocation 40/30/30 → taste_unheard 35 / dormant 30 /
cross_user 20 / random 15, and lead the interleave with taste_unheard so
a track shared with another bucket keeps the taste stamp and the
targeted-novelty arm stays measurable. Metrics label "Taste-matched" +
order entry added to the single server-side pickKindLabels map, so web
and Android surface the new breakdown row with no client change.

Cold start (empty taste_profile_tags) yields an empty taste bucket that
redistributes to the survivors, so Discover still fills.

Scribe #1488. Companion review outcomes: Songs-like starvation already
fixed (#1255); For You v2 ratified as-is (fresh-injection cost disproven).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:16:04 -04:00
bvandeusenandClaude Opus 4.8 772a52b23e feat(home): "Updating your mixes…" veil over daily-rebuild churn
android / Build + lint + test (push) Successful in 3m58s
The 03:00 system-playlist rebuild (playlist.system_rebuilt) re-pulls
Home, and refreshIndex() rewrites every section delete-then-insert — so
all 7 rows + the Playlists row visibly collapse to empty, refill with
skeletons, then pop in per-tile as metadata hydrates. Read as a lot of
busy on-screen movement.

Raise an "Updating your mixes…" veil for automatic refreshes only (daily
rebuild + reconnect re-pull): HomeViewModel.isUpdating, driven by a new
refreshBehindVeil() the event/recovery collectors call in place of
refresh(). It holds through the pull plus a short settle so hydration
lands behind the veil, then wipes off. Manual pull-to-refresh keeps its
PullToRefreshBox spinner; cold start keeps the skeleton.

The veil is a near-opaque, background-tinted overlay that wipes in from
the left and swallows taps while raised. Extracted HomeStateCrossfade so
HomeScreen stays under detekt's LongMethod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:52:42 -04:00
bvandeusen 725ddca950 Merge pull request 'Milestone 127: recommendation quality — provenance, tiered mixes, For You v2, tuning lab' (#107) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 34s
release / Build + push container image (push) Successful in 36s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m41s
2026-07-03 10:13:49 -04:00