Compare commits

...
114 Commits
Author SHA1 Message Date
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
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
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
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
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
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
bvandeusen d145fee35d Merge pull request 'fix(android/resilience): auto-recover failed loads on reconnect' (#106) from dev into main
android / Build + lint + test (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 16s
2026-07-02 16:45:13 -04:00
bvandeusen 611715154b Merge pull request 'M9 diagnostics follow-ups: playback relabel, sort, connected fix, per-skip + track-identity' (#105) from dev into main
test-go / test (push) Successful in 38s
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m18s
test-go / integration (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 3m46s
release / Build + push container image (push) Successful in 16s
2026-06-30 19:19:15 -04:00
bvandeusen 23a82fb38d Merge pull request 'M9 — Device diagnostics & debug reporting (connectivity + UPnP desync)' (#104) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 1m38s
test-go / test (push) Successful in 35s
android / Build + lint + test (push) Successful in 4m6s
test-go / integration (push) Successful in 4m37s
test-web / test (push) Successful in 47s
2026-06-29 19:24:16 -04:00
bvandeusen 0de2437689 Merge pull request 'Image rendering + player resilience (#968, #980)' (#103) from dev into main
android / Build + lint + test (push) Successful in 4m11s
test-go / test (push) Successful in 46s
test-web / test (push) Successful in 55s
test-go / integration (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 3m53s
release / Build + push container image (push) Successful in 1m41s
2026-06-20 20:57:39 -04:00
bvandeusen a251dce7e3 Merge pull request 'feat: scan on startup by default + README first-run walkthrough & screenshots' (#102) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 37s
release / Build + push container image (push) Successful in 1m44s
test-go / integration (push) Successful in 4m35s
2026-06-20 11:40:56 -04:00
bvandeusen 938dae7163 Merge pull request 'docs: correct README setup/OOBE + fix data-volume mount' (#101) from dev into main 2026-06-20 10:58:07 -04:00
bvandeusen 93365cb555 Merge pull request 'ci(release): bundle the latest release APK into non-tag :latest builds' (#100) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 17s
2026-06-14 22:55:23 -04:00
bvandeusen eeabdf1f2c Merge pull request 'Web UI: Most Played hover fix, narrower seek bar, Android-parity track kebab' (#99) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 35s
release / Build + push container image (push) Successful in 1m50s
2026-06-14 22:26:52 -04:00
bvandeusen 3e258507bb Merge pull request 'fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening' (#98) from dev into main
android / Build + lint + test (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 15s
2026-06-12 21:25:25 -04:00
bvandeusen 9550d8daaf Merge pull request 'fix(android): hold WiFi+wake lock during UPnP cast (locked-screen poll starvation)' (#97) from dev into main
android / Build + lint + test (push) Successful in 4m5s
release / Build signed APK (tag releases only) (push) Successful in 4m4s
release / Build + push container image (push) Successful in 13s
2026-06-12 19:45:44 -04:00
bvandeusen db393bbe65 Merge PR #96: recommendation batch (You-might-like fallback + taste 2b + observability)
test-go / test (push) Successful in 41s
test-web / test (push) Successful in 43s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-12 01:10:09 -04:00
bvandeusen 7b7bd0c3e8 Merge PR #95: You-might-like liked-entity fallback (#790)
test-go / test (push) Successful in 30s
release / Build signed APK (tag releases only) (push) Successful in 3m55s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-11 23:33:25 -04:00
bvandeusen 8019537b02 Merge PR #94: "You might like" web client row (#790)
test-web / test (push) Successful in 35s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 1m56s
2026-06-11 23:01:38 -04:00
bvandeusen e41d603c12 Merge PR #93: move "You might like" under system-playlists row
android / Build + lint + test (push) Successful in 4m24s
release / Build signed APK (tag releases only) (push) Successful in 4m29s
release / Build + push container image (push) Successful in 14s
2026-06-11 22:55:52 -04:00
bvandeusen a62f07bd3a Merge PR #92: "You might like" Android client row (#790)
release / Build signed APK (tag releases only) (push) Successful in 4m12s
release / Build + push container image (push) Successful in 15s
android / Build + lint + test (push) Successful in 4m19s
2026-06-11 22:36:22 -04:00
bvandeusen 3c646c6974 Merge PR #91: "You might like" rows + taste profile (learn + apply)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m34s
release / Build signed APK (tag releases only) (push) Successful in 4m5s
release / Build + push container image (push) Successful in 15s
2026-06-11 21:41:17 -04:00
bvandeusen 9a57dc4bec Merge pull request 'Offline/playback recording robustness (contract-audit follow-ups)' (#90) from dev into main
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 4m25s
release / Build signed APK (tag releases only) (push) Successful in 4m20s
release / Build + push container image (push) Successful in 1m53s
2026-06-11 14:19:27 -04:00
bvandeusen f167ddfbfb Merge pull request 'fix: restore native Android play-event recording (History was empty)' (#89) from dev into main
test-go / test (push) Successful in 38s
test-go / integration (push) Successful in 4m29s
android / Build + lint + test (push) Successful in 4m42s
release / Build signed APK (tag releases only) (push) Successful in 3m59s
release / Build + push container image (push) Successful in 14s
2026-06-11 09:08:20 -04:00
bvandeusen 962b4dbc8c Merge pull request 'v2026.06.07 — Sonos: one SOAP failure no longer drops to local' (#88) from dev into main
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 15s
2026-06-07 19:14:45 -04:00
bvandeusen aa4089118e chore: configure Renovate (tuned)
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 1m34s
Activate Renovate with a tuned config: target dev, ignore retired
flutter_client/**, auto-merge GREEN patch/minor bumps, hold majors behind
dependency-dashboard approval, and group go/CI/docker/gradle/npm updates.
Throttled to a weekend schedule with prHourlyLimit 2.
2026-06-07 12:00:44 -04:00
bvandeusen 7c791dc8e4 v2026.06.06 — UPnP recovery, library watcher, artist discovery, request auto-poll (#87)
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
release / Build + push container image (push) Successful in 14s
2026-06-06 23:31:27 -04:00
bvandeusen 11466e1525 Merge pull request 'Unify offline detection + offline playlist UX (NetworkStatusController)' (#86) from dev into main
release / Build + push container image (push) Successful in 13s
android / Build + lint + test (push) Successful in 3m56s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
2026-06-05 13:27:36 -04:00
bvandeusen 301c3bfb86 Merge pull request 'fix(android): don't flip offline on WAN-validation flicker — trust /healthz' (#85) from dev into main
android / Build + lint + test (push) Successful in 5m6s
release / Build signed APK (tag releases only) (push) Successful in 4m42s
release / Build + push container image (push) Successful in 14s
2026-06-04 23:02:28 -04:00
bvandeusen 4d8c7d6566 Merge pull request 'fix(android): notification art uses album cover, not embedded stream tags' (#84) from dev into main
android / Build + lint + test (push) Successful in 4m1s
release / Build signed APK (tag releases only) (push) Successful in 6m12s
release / Build + push container image (push) Successful in 15s
2026-06-04 22:35:21 -04:00
bvandeusen d6e6caa223 Merge pull request 'Sonos queue resync + cold-start prefetcher gate' (#83) from dev into main
android / Build + lint + test (push) Successful in 4m19s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 13s
2026-06-04 17:36:31 -04:00
bvandeusen 8b08482d13 Merge pull request 'fix(android): hysteresis on /healthz reachable signal' (#82) from dev into main
android / Build + lint + test (push) Successful in 4m46s
release / Build signed APK (tag releases only) (push) Successful in 4m26s
release / Build + push container image (push) Successful in 13s
2026-06-04 14:23:49 -04:00
bvandeusen 7cf04fe24b Merge pull request 'Android #618 offline-mode UX + Sonos polish + server DRY' (#81) from dev into main
android / Build + lint + test (push) Successful in 4m35s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 13s
2026-06-04 12:53:59 -04:00
bvandeusen 222a0ff636 Merge pull request 'dev → main: collage center-crop, server DRY, CI durability-off' (#80) from dev into main
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m27s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 12s
2026-06-04 08:42:36 -04:00
bvandeusen d75c1ae37f Merge pull request 'dev → main: Android UPnP/Sonos transport parity + server stream URL extension' (#79) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 30s
release / Build + push container image (push) Successful in 1m24s
android / Build + lint + test (push) Successful in 4m12s
test-go / integration (push) Successful in 9m16s
2026-06-04 08:15:15 -04:00
bvandeusen 3c4c27fb08 Merge pull request 'v2026.06.03 hotfix — Sonos cast URL + UPnP picker polish' (#78) from dev into main
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m49s
test-go / integration (push) Successful in 10m56s
release / Build signed APK (tag releases only) (push) Successful in 4m15s
release / Build + push container image (push) Successful in 17s
2026-06-03 15:30:09 -04:00
bvandeusen a62a20b599 Merge pull request 'v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation' (#77) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 4m22s
test-go / integration (push) Successful in 11m14s
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m59s
release / Build + push container image (push) Successful in 2m6s
2026-06-03 14:09:23 -04:00
bvandeusen d9b2dd957c Merge pull request 'fix(android): interceptor order — auth before baseUrl (hotfix for v2026.06.02)' (#76) from dev into main
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m28s
release / Build + push container image (push) Successful in 2m1s
2026-06-02 22:07:32 -04:00
bvandeusen 46dcd38fd8 Merge pull request 'Drift audit 2026-06-02 — 26 findings shipped' (#75) from dev into main
test-go / test (push) Successful in 43s
test-web / test (push) Successful in 56s
release / Build signed APK (tag releases only) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 24s
android / Build + lint + test (push) Successful in 5m47s
test-go / integration (push) Failing after 12m37s
2026-06-02 19:21:53 -04:00
bvandeusen 7838038047 Merge pull request 'Playback errors slice + scrubber polish + various polish' (#74) from dev into main
test-go / test (push) Successful in 39s
test-web / test (push) Successful in 51s
android / Build + lint + test (push) Successful in 4m45s
release / Build signed APK (tag releases only) (push) Successful in 5m12s
release / Build + push container image (push) Successful in 22s
test-go / integration (push) Successful in 12m7s
2026-06-02 14:14:11 -04:00
bvandeusen b64965b38d Merge pull request 'Discover artwork + Library icon + notification tap routing' (#73) from dev into main
android / Build + lint + test (push) Successful in 5m55s
release / Build signed APK (tag releases only) (push) Successful in 5m26s
release / Build + push container image (push) Successful in 16s
2026-06-02 09:58:10 -04:00
bvandeusen bf2f9f3811 Merge pull request 'Lock Android MainActivity to portrait' (#72) from dev into main
android / Build + lint + test (push) Successful in 4m2s
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 15s
2026-06-02 08:19:07 -04:00
bvandeusen deb726a285 Merge pull request 'Keep onPostScroll under detekt ReturnCount limit' (#71) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 10s
android / Build + lint + test (push) Successful in 4m9s
2026-06-02 08:10:12 -04:00
bvandeusen 7ede83a586 Merge pull request 'Alphabet rail page-chasing + Songs Like fix + scrubber polish' (#70) from dev into main
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Failing after 1m29s
release / Build signed APK (tag releases only) (push) Successful in 3m51s
release / Build + push container image (push) Successful in 12s
2026-06-01 23:36:07 -04:00
bvandeusen 1d7b91333f Merge pull request 'Web Library: alphabet rail always shows #/A-Z/&' (#69) from dev into main
test-web / test (push) Successful in 45s
release / Build + push container image (push) Successful in 19s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
2026-06-01 21:55:49 -04:00
bvandeusen 883d416d26 Merge pull request 'Web Library: continuous grid + sticky alphabet rail' (#68) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 28s
test-web / test (push) Successful in 35s
2026-06-01 21:36:55 -04:00
bvandeusen 09471a8f5c Merge pull request 'Web: Most Played horizontal tiles + nav centering + Library link + Search refinements' (#67) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 25s
test-web / test (push) Successful in 34s
2026-06-01 21:06:03 -04:00
bvandeusen 7de238e91e Merge pull request 'Web Home: drop hero row + compact Rediscover tiles' (#66) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 26s
test-web / test (push) Successful in 35s
2026-06-01 20:43:53 -04:00
bvandeusen 9440c5860b Merge pull request 'Android v1 polish + Web UI flavor pass' (#65) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 40s
release / Build + push container image (push) Successful in 41s
android / Build + lint + test (push) Successful in 4m29s
2026-06-01 20:17:49 -04:00
bvandeusen 082d31bfa9 Merge pull request 'ci: chain APK build → image build via needs (fix race, kill polling)' (#64) from dev into main
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m47s
release / Build + push container image (push) Successful in 14s
2026-06-01 18:31:12 -04:00
bvandeusen 8847b43d9e Merge pull request 'ci(android): make APK attach failures visible (debug v2026.06.01 missing asset)' (#63) from dev into main
release / release (push) Successful in 11s
android / Build + lint + test (push) Successful in 4m16s
android / Build signed release APK (push) Successful in 3m9s
2026-06-01 18:13:43 -04:00
bvandeusen 09cc810e5a Merge pull request 'ci: pin upload-artifact to v3 (Gitea GHES-mode incompatible with v4)' (#62) from dev into main
release / release (push) Successful in 11s
android / Build signed release APK (push) Has been skipped
android / Build + lint + test (push) Successful in 4m34s
2026-06-01 17:38:24 -04:00
bvandeusen 2534384ed1 Merge pull request 'fix(android): remove WorkManager auto-initializer (lintVitalRelease)' (#61) from dev into main
android / Build + lint + test (push) Successful in 3m42s
android / Build signed release APK (push) Successful in 3m22s
release / release (push) Successful in 7m46s
2026-06-01 16:55:52 -04:00
bvandeusen 9ff1b30e3f Merge pull request 'fix(docker): bump builder to go 1.25 to match go.mod' (#60) from dev into main
android / Build + lint + test (push) Successful in 3m36s
android / Build signed release APK (push) Failing after 3m21s
release / release (push) Successful in 15m17s
2026-06-01 16:13:43 -04:00
bvandeusen c01853577b Merge pull request 'Release: web UX overhaul + Android native port + server polish' (#59) from dev into main
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 44s
release / release (push) Failing after 15m9s
android / Build + lint + test (push) Successful in 4m49s
android / Build signed release APK (push) Failing after 3m32s
test-go / integration (push) Successful in 9m33s
2026-06-01 16:11:21 -04:00
bvandeusen 747ed4134b Merge pull request 'v2026.05.21.0 — Wear OS dispatch fix, playlist-load feedback, drift CI' (#58) from dev into main 2026-05-21 15:42:06 -04:00
bvandeusen ccbd3b62a0 Merge pull request 'v2026.05.19.3 — playback stall resilience + legacy home cleanup' (#56) from dev into main 2026-05-19 15:48:08 -04:00
bvandeusen 19de0c2874 Merge pull request 'v2026.05.19.2 — hotfix: restore media notification (remove broken custom favorite)' (#55) from dev into main
Merge v2026.05.19.2 hotfix — restore media notification (PR #55)
2026-05-19 07:48:10 -04:00
bvandeusen 22a4649bfc Merge pull request 'v2026.05.19.1 — hotfix: notification permission + full-player auto-minimize' (#54) from dev into main
Merge v2026.05.19.1 hotfix — notification permission + full-player auto-minimize (PR #54)
2026-05-18 23:08:33 -04:00
bvandeusen 8e7660c05e Merge pull request 'fix(ci): scope integration Postgres discovery to this job's network' (#53) from dev into main
Merge CI fix: scope integration Postgres discovery to this job's network (PR #53)
2026-05-18 22:50:36 -04:00
bvandeusen 53be834e89 Merge pull request 'v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration' (#52) from dev into main
Merge v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration (PR #52)
2026-05-18 21:42:48 -04:00
bvandeusen 0d410630a2 Merge pull request 'Release v2026.05.18.0 — integration-tests-in-CI + recommendations/cover-art/discover batch' (#51) from dev into main 2026-05-17 22:41:20 -04:00
bvandeusen 62db8edcdb Merge pull request 'Release v2026.05.16.0 — recommendations working end-to-end + cover-art uncap' (#50) from dev into main 2026-05-16 18:58:55 -04:00
bvandeusen ec0cc37bc9 Merge pull request 'Hotfix v2026.05.15.1 — allow discovery-mix variants in playlists CHECK constraints' (#49) from dev into main 2026-05-16 00:32:25 -04:00
bvandeusen e772938a3b Merge pull request 'Release v2026.05.15.0 — system playlists v2, offline cache rework, CI speedup' (#48) from dev into main 2026-05-15 23:15:48 -04:00
bvandeusen 29fee5aa37 Merge pull request 'Release v2026.05.14.0 — player polish, CacheFiller, offline mutation queue' (#47) from dev into main 2026-05-15 01:17:44 +00:00
bvandeusen e5ab471ce1 Merge pull request 'release v2026.05.13.3: full-player seed + MediaSession expansion (Wear)' (#46) from dev into main 2026-05-14 18:19:39 +00:00
bvandeusen 573aa4226d Merge pull request 'release v2026.05.13.2: artist covers + load-then-swap player transitions' (#45) from dev into main 2026-05-14 16:31:43 +00:00
bvandeusen 7339815ea9 Merge pull request 'release v2026.05.13.1: player + Discover hotfix' (#44) from dev into main 2026-05-14 15:17:45 +00:00
bvandeusen baa601765e Merge pull request 'release v2026.05.13.0: SSE live updates + offline cache + per-item rendering' (#43) from dev into main 2026-05-14 02:36:47 +00:00
bvandeusen 0d009b34e2 Merge pull request 'release v2026.05.12.1: Discover surface + nav fixes + cache hygiene' (#42) from dev into main 2026-05-12 03:58:43 +00:00
bvandeusen f1b4652c77 Merge pull request 'release v2026.05.11.3: caching, perf, playlist polish' (#41) from dev into main 2026-05-12 00:35:48 +00:00
bvandeusen e610948307 Merge pull request 'release v2026.05.11.2: signing key + library infinite scroll' (#40) from dev into main 2026-05-11 19:51:41 +00:00
bvandeusen fb811804d2 Merge pull request 'release v2026.05.11.1: Flutter caching, navigation, and player polish' (#39) from dev into main 2026-05-11 17:47:41 +00:00
bvandeusen 37134950a5 Merge pull request 'release: M5-M7 server + web + Flutter, plus Flutter v1 polish' (#38) from dev into main 2026-05-11 15:21:58 +00:00
bvandeusen fcded9294c Merge pull request 'fix(web): download button + server version display' (#37) from dev into main 2026-05-11 03:20:39 +00:00
bvandeusen 42abb7adff Merge pull request 'ci: unify REGISTRY_TOKEN + RELEASE_TOKEN into CI_TOKEN' (#36) from dev into main 2026-05-11 01:45:01 +00:00
bvandeusen 04933f2d9f Merge pull request 'M5–M7 sweep + offline cache + in-app updates + DRY pass' (#35) from dev into main 2026-05-11 01:09:36 +00:00
bvandeusen 626fc7502c Merge pull request 'refactor(server): remove bootstrap admin path' (#34) from dev into main 2026-05-09 02:19:40 +00:00
bvandeusen 9bf3b8a2f2 Merge pull request 'feat(flutter): admin parity slice — requests, quarantine, users, invites' (#33) from dev into main 2026-05-09 02:07:59 +00:00
bvandeusen 492460cf4a Merge pull request 'fix(web): /register reachable for bootstrap admin (closes #376)' (#32) from dev into main 2026-05-08 22:01:37 +00:00
bvandeusen 1c775905d7 Release v2026.05.08.1 — DRY pass + cover-art HTTP base (#31)
Merges ~50 commits from dev: full DRY pass round 1 + round 2, plus PR3 cover-art HTTP base.
2026-05-08 17:45:41 +00:00
bvandeusen cd2ff648d0 Merge pull request 'M5a frontend + M5b quarantine + M5c suggestions' (#30) from dev into main 2026-05-01 11:14:18 +00:00
98 changed files with 4923 additions and 524 deletions
+10 -4
View File
@@ -80,10 +80,16 @@ jobs:
- name: Upload debug APK
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
# Pin to @v3 until act_runner or the artifact backend catches up.
uses: actions/upload-artifact@v3
# Mirrored action, never actions/upload-artifact. @v4+ throws
# GHESNotSupportedError client-side on the hostname (no server setting
# reaches that check), and @v3 is worse — it reports success while Gitea
# serves artifacts back only through the v4 API, so the upload is stored
# and invisible to every retrieval path. @v3 is what left 72 unreachable
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
# See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-android-debug-${{ github.sha }}
path: android/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
+24 -4
View File
@@ -131,12 +131,19 @@ jobs:
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
- name: Upload APK as workflow artifact
# @v3 because Gitea Actions emulates GHES and the v2 artifact
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
uses: actions/upload-artifact@v3
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# hostname, @v3 uploads something Gitea will never serve back. This is
# the producing half of a pair: image-release downloads `minstrel-apk`
# below with the matching download-artifact mirror. Both must stay on
# the v4 protocol — mixing a v3 upload with a v4 download (or the
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.apk
# error, not the default warn: image-release hard-depends on this
# artifact existing, so an empty upload must fail here, not there.
if-no-files-found: error
- name: Attach APK to gitea Release
shell: bash
@@ -238,7 +245,20 @@ jobs:
# Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead.
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
uses: actions/download-artifact@v3
# Consuming half of the pair — never actions/download-artifact. Same fork,
# same reason: upstream's client-side GHES check rejects this hostname
# before it connects. bvandeusen/download-artifact mirrors
# code.forgejo.org/forgejo/download-artifact.
#
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# the action's own version number — the two actions release on unrelated
# cadences, and download v5 would pair a ^2.3.2 client with this file's
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
# the same one proven against this instance by the upload side.
# Deliberately NOT v7: it moves to node24 and upstream requires runner
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
# Pinned, not tagged — the mirror auto-syncs every 8h.
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
with:
name: minstrel-apk
path: client/
+14 -1
View File
@@ -210,4 +210,17 @@ dependencies {
debugImplementation(libs.compose.ui.test.manifest)
}
tasks.withType<Test> { useJUnitPlatform() }
tasks.withType<Test> {
useJUnitPlatform()
// Print the assertion message + full stack trace for failures. The
// default console output gives only "AssertionError at Foo.kt:12", and
// for a failure inside a `runTest { }` lambda even that line collapses
// to the test function's own line (the assertion frames live in the
// suspend-lambda class, which Gradle filters out) — leaving nothing to
// debug from when the HTML report isn't reachable, as in CI.
testLogging {
events("failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showStackTraces = true
}
}
@@ -6,6 +6,7 @@ import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
@@ -29,6 +30,10 @@ import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
// Cover-art fade-in. Coil skips the transition for memory-cache hits, so
// already-loaded art still appears instantly — only a genuine fetch fades.
private const val ART_CROSSFADE_MS = 220
@HiltAndroidApp
class MinstrelApplication :
Application(),
@@ -213,11 +218,18 @@ class MinstrelApplication :
* OkHttp client as the network fetcher. The `callFactory` lambda
* is invoked lazily so Hilt has time to inject `okHttpClient`
* before Coil makes its first request.
*
* Crossfade is set here rather than per-call so every cover surface
* in the app fades its artwork in instead of snapping it. Art
* landing a beat after its tile was the most visible pop-in on Home
* (issue #2327); `ServerImage` fades its placeholder out over the
* same window so the two read as one cross-dissolve.
*/
override fun newImageLoader(context: android.content.Context): ImageLoader =
ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
}
.crossfade(ART_CROSSFADE_MS)
.build()
}
@@ -0,0 +1,48 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the tag-enrichment provider settings (#1521).
* No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
*/
@Singleton
class AdminTagSourcesRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminTagSourcesApi = retrofit.create()
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
}
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
id = id,
displayName = displayName,
requiresApiKey = requiresApiKey,
supports = supports,
enabled = enabled,
apiKeySet = apiKeySet,
testable = testable,
)
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.TriangleAlert
import com.composables.icons.lucide.Users
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -54,7 +57,7 @@ import javax.inject.Inject
// ─── State ───────────────────────────────────────────────────────────
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
sealed interface AdminLandingUiState {
data object Loading : AdminLandingUiState
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
private val requestsRepo: AdminRequestsRepository,
private val quarantineRepo: AdminQuarantineRepository,
private val usersRepo: AdminUsersRepository,
private val tagSourcesRepo: AdminTagSourcesRepository,
) : ViewModel() {
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
val req = async { requestsRepo.list().size }
val qua = async { quarantineRepo.list().size }
val usr = async { usersRepo.list().size }
AdminCounts(req.await(), qua.await(), usr.await())
val tag = async { tagSourcesRepo.list().count { it.enabled } }
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
}
internal.value = AdminLandingUiState.Success(counts)
} catch (
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
onClick = { navController.navigate(AdminUsers) },
)
}
item {
SectionCard(
icon = Lucide.Music,
title = "Tag sources",
subtitle = "Metadata enrichment providers",
count = counts.tagSources,
onClick = { navController.navigate(AdminTagSources) },
)
}
}
}
@@ -0,0 +1,220 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminTagSourcesScreen(
navController: NavHostController,
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin · Tag sources",
navController = navController,
currentRouteName = AdminTagSources::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminTagSourcesUiState.Loading -> LoadingCentered()
AdminTagSourcesUiState.Empty -> EmptyState(
title = "No tag sources",
body = "Tag-enrichment providers register on the server; none are available.",
)
is AdminTagSourcesUiState.Error -> ErrorRetry(
title = "Couldn't load tag sources",
message = s.message,
onRetry = { viewModel.refresh() },
)
is AdminTagSourcesUiState.Success -> TagSourceList(
providers = s.providers,
testResults = s.testResults,
onToggle = viewModel::setEnabled,
onSaveKey = viewModel::saveApiKey,
onTest = viewModel::test,
)
}
}
}
}
@Composable
private fun TagSourceList(
providers: List<AdminTagSourceRef>,
testResults: Map<String, TagSourceTestResult>,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = providers, key = { it.id }) { provider ->
TagSourceCard(
provider = provider,
testResult = testResults[provider.id],
onToggle = onToggle,
onSaveKey = onSaveKey,
onTest = onTest,
)
}
}
}
@Composable
private fun TagSourceCard(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
Text(
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = provider.enabled,
onCheckedChange = { onToggle(provider.id, it) },
)
}
if (provider.requiresApiKey) {
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
}
if (provider.testable) {
TestRow(provider = provider, testResult = testResult, onTest = onTest)
}
}
}
}
@Composable
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
var key by remember(provider.id) { mutableStateOf("") }
OutlinedTextField(
value = key,
onValueChange = { key = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("API key") },
placeholder = {
Text(
if (provider.apiKeySet) {
"••• saved — leave blank to keep"
} else {
"Paste your API key to enable this source"
},
)
},
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
onSaveKey(provider.id, key)
key = ""
},
enabled = key.isNotBlank(),
) { Text("Save key") }
if (provider.apiKeySet) {
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun TestRow(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onTest: (String) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
testResult?.let { result ->
if (result.ok) {
Text(
text = "OK (${result.durationMs}ms)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
text = "Failed — ${result.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -0,0 +1,116 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AdminTagSourcesUiState {
data object Loading : AdminTagSourcesUiState
data object Empty : AdminTagSourcesUiState
data class Success(
val providers: List<AdminTagSourceRef>,
val testResults: Map<String, TagSourceTestResult>,
) : AdminTagSourcesUiState
data class Error(val message: String) : AdminTagSourcesUiState
}
@HiltViewModel
class AdminTagSourcesViewModel @Inject constructor(
private val repository: AdminTagSourcesRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery: reload a failed list when server
// health returns instead of waiting for a manual pull (issue #1245).
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh()
}
}
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminTagSourcesUiState.Loading
try {
val providers = repository.list()
internal.value = if (providers.isEmpty()) {
AdminTagSourcesUiState.Empty
} else {
AdminTagSourcesUiState.Success(providers, emptyMap())
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
}
}
fun setEnabled(id: String, enabled: Boolean) {
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
// Optimistic toggle; reconcile via refresh() if the server rejects.
internal.value = before.copy(
providers = before.providers.map {
if (it.id == id) it.copy(enabled = enabled) else it
},
)
viewModelScope.launch {
try {
repository.setEnabled(id, enabled)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun saveApiKey(id: String, apiKey: String) {
viewModelScope.launch {
try {
replaceProvider(repository.setApiKey(id, apiKey))
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun test(id: String) {
viewModelScope.launch {
val result = try {
repository.test(id)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
}
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
internal.value = current.copy(testResults = current.testResults + (id to result))
}
}
private fun replaceProvider(updated: AdminTagSourceRef) {
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
internal.value = current.copy(
providers = current.providers.map { if (it.id == updated.id) updated else it },
)
}
}
@@ -0,0 +1,41 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
* surface for the tag-enrichment provider settings (#1490). Mirrors the
* web integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings.
*/
interface AdminTagSourcesApi {
@GET("api/admin/tag-sources")
suspend fun list(): AdminTagSourcesListWire
@PATCH("api/admin/tag-sources/{id}")
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
@POST("api/admin/tag-sources/{id}/test")
suspend fun test(@Path("id") id: String): TestTagSourceWire
}
/**
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
* untouched one (the server reads a missing/null field as "leave
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
*/
@Serializable
data class UpdateTagSourceBody(
val enabled: Boolean? = null,
@SerialName("api_key") val apiKey: String? = null,
)
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import kotlinx.coroutines.flow.Flow
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
)
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
/** True when Home has any cached section rows to render. */
@Query("SELECT EXISTS(SELECT 1 FROM cached_home_index)")
suspend fun hasAny(): Boolean
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
/** Replace-all pattern; sync wipes a section then re-inserts. */
@Query("DELETE FROM cached_home_index WHERE section = :section")
suspend fun deleteBySection(section: String)
@Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
suspend fun deleteSections(sections: List<String>)
/**
* Swaps every listed section's rows in ONE transaction.
*
* Atomicity is the point, not just tidiness: Room's
* InvalidationTracker only notifies observers after the transaction
* commits, so [observeBySection] never sees the empty gap between the
* delete and the re-insert. Replacing sections one at a time (and
* un-transacted) made each Home row emit `emptyList()` — visibly
* collapsing — before refilling, and made the seven sections do it in
* sequence rather than as a single content swap.
*/
@Transaction
suspend fun replaceSections(sections: List<String>, rows: List<CachedHomeIndexEntity>) {
deleteSections(sections)
if (rows.isNotEmpty()) upsertAll(rows)
}
@Query("DELETE FROM cached_home_index")
suspend fun clear()
@@ -239,6 +239,9 @@ data class PlayOfflinePayload(
val atIso: String,
val durationPlayedMs: Long,
val source: String? = null,
// #1551: device class for context conditioning; null on payloads queued
// before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null,
)
/**
@@ -254,6 +254,7 @@ class MutationReplayer @Inject constructor(
at = decoded.atIso,
durationPlayedMs = decoded.durationPlayedMs,
source = decoded.source,
deviceClass = decoded.deviceClass,
),
)
return Outcome.SENT
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -34,9 +36,10 @@ import javax.inject.Singleton
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
* per-item tile providers.
*
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
* in-place (delete-then-insert, so the section Flows re-fire), and
* pre-warms the top artists via [HomeArtistPrewarmer].
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
* one transaction (so the rows update together in a single emission
* rather than collapsing and refilling), and pre-warms the top artists
* via [HomeArtistPrewarmer].
*/
@Singleton
// Per-section observe accessors (one per Home row) inflate the function
@@ -85,72 +88,98 @@ class HomeRepository @Inject constructor(
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
/** True when the index cache already has content on screen to protect. */
suspend fun hasCachedIndex(): Boolean = homeIndexDao.hasAny()
/**
* Pulls /api/home/index, replaces each cached_home_index section,
* and pre-warms the top artists. The section Flows re-fire on the
* index change; missing entity rows hydrate via the on-miss path.
* Pulls /api/home/index and swaps every cached_home_index section in
* a single transaction, then pre-warms the top artists. Missing
* entity rows hydrate via the on-miss path.
*
* One transaction for all seven sections is deliberate: Room notifies
* observers once, on commit, so Home swaps from the old content to
* the new in a single emission. Per-section, un-transacted writes
* made every row visibly collapse to empty and refill, one after
* another (issue #2327).
*/
suspend fun refreshIndex() {
val wire = api.getHomeIndex()
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
homeIndexDao.replaceSections(
sections = ALL_SECTIONS,
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
rowsFor(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) +
rowsFor(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums) +
rowsFor(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists),
)
prewarmer.warm(
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
)
}
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
homeIndexDao.deleteBySection(section)
if (ids.isEmpty()) return
homeIndexDao.upsertAll(
ids.mapIndexed { index, id ->
CachedHomeIndexEntity(
section = section,
position = index,
entityType = entityType,
entityId = id,
)
},
private fun rowsFor(
section: String,
entityType: String,
ids: List<String>,
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
CachedHomeIndexEntity(
section = section,
position = index,
entityType = entityType,
entityId = id,
)
}
/**
* The section's ordered entity ids, deduplicated.
*
* Room re-runs the query on every write to `cached_home_index` — and
* `CachedHomeIndexEntity.fetchedAt` is stamped fresh each time — so
* comparing whole rows would call every rewrite a change. Comparing
* the id list instead means a section whose contents didn't actually
* move never restarts the `flatMapLatest` below, which would
* otherwise tear down and rebuild all of its tiles' hydration flows
* and flicker unchanged tiles (issue #2327).
*/
private fun observeSectionIds(section: String): Flow<List<String>> =
homeIndexDao.observeBySection(section)
.map { rows -> rows.map { it.entityId } }
.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
observeSectionIds(section).flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
observeSectionIds(section).flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (rows.isEmpty()) {
observeSectionIds(section).flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
}
}
}
@@ -163,5 +192,16 @@ class HomeRepository @Inject constructor(
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
/** Every section [refreshIndex] owns — the unit of one atomic swap. */
val ALL_SECTIONS = listOf(
SECTION_RECENTLY_ADDED_ALBUMS,
SECTION_REDISCOVER_ALBUMS,
SECTION_REDISCOVER_ARTISTS,
SECTION_MOST_PLAYED_TRACKS,
SECTION_LAST_PLAYED_ARTISTS,
SECTION_YOU_MIGHT_LIKE_ALBUMS,
SECTION_YOU_MIGHT_LIKE_ARTISTS,
)
}
}
@@ -43,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -85,30 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.shared.UpdateVeilController
import com.fabledsword.minstrel.shared.VeilOutcome
import com.fabledsword.minstrel.shared.VeilSessionResult
import com.fabledsword.minstrel.shared.VeilSettleState
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -120,16 +129,22 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
private const val RECENTLY_ADDED_GRID_ROWS = 2
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil (automatic refresh). Held through the pull
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_SETTLE_MS = 500L
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
// appears at all — only when a refresh actually changes something — and how
// long it stays, by watching the screen settle rather than by a fixed delay,
// which lowered it while tiles and artwork were still landing (#2327).
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f
private const val VEIL_SPINNER_DP = 22
private const val VEIL_SPINNER_STROKE_DP = 2
private const val VEIL_LABEL_GAP_DP = 12
// Backstop on how long a manual pull keeps its own indicator while waiting
// for its successor — the veil, or the "already up to date" snackbar — so the
// two never both vanish for a frame mid-handoff.
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
// ─── State ───────────────────────────────────────────────────────────
data class HomeSections(
@@ -184,10 +199,13 @@ class HomeViewModel @Inject constructor(
initialValue = false,
)
private val poolMessages = Channel<String>(Channel.BUFFERED)
private val snackbarMessages = Channel<String>(Channel.BUFFERED)
/** Transient snackbar messages from offline-pool taps. */
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
/**
* Transient snackbar messages: offline-pool taps, playback failures, and
* the outcome of a refresh the user explicitly asked for.
*/
val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()
/**
* Copy for the most recent /home/index refresh failure; null once a
@@ -197,38 +215,13 @@ class HomeViewModel @Inject constructor(
*/
private val refreshError = MutableStateFlow<String?>(null)
private val updatingInternal = MutableStateFlow(false)
/**
* True while an automatic background refresh (the 03:00 daily rebuild
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
* your mixes…" veil so the section churn — delete-then-insert in
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
* hidden behind the veil instead of on screen. Manual pull-to-refresh
* and cold start are NOT veiled (they own the pull spinner / skeleton).
* Cover-art loads in flight on Home, reported by every [ServerImage]
* under [LocalArtSettleTracker]. The veil waits on this so artwork
* arriving a beat after its tile lands behind the veil rather than
* popping in on screen.
*/
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery (issue #1245): a Home that failed to
// load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up.
// Veiled: content is already on screen and would otherwise churn.
viewModelScope.launch {
networkStatus.recoveries().collect { refreshBehindVeil() }
}
// #968: the daily 03:00 rebuild (and manual refresh) emit
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
// rebuild churn hides behind "Updating your mixes…".
viewModelScope.launch {
eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" }
.collect { refreshBehindVeil() }
}
}
val artTracker = ArtSettleTracker()
/**
* Tap an offline pool: shuffle + play its cached tracks. Empty
@@ -241,7 +234,7 @@ class HomeViewModel @Inject constructor(
OfflinePoolKind.LIKED -> shuffleSource.liked()
}.shuffled()
if (tracks.isEmpty()) {
poolMessages.trySend("No cached ${kind.label} tracks yet")
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
} else {
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
}
@@ -278,14 +271,14 @@ class HomeViewModel @Inject constructor(
try {
val detail = libraryRepository.refreshAlbumDetail(albumId)
if (detail.tracks.isEmpty()) {
poolMessages.trySend("This album has no tracks to play.")
snackbarMessages.trySend("This album has no tracks to play.")
} else {
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
snackbarMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
)
}
@@ -303,14 +296,14 @@ class HomeViewModel @Inject constructor(
try {
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
if (tracks.isEmpty()) {
poolMessages.trySend("This artist has no tracks to play.")
snackbarMessages.trySend("This artist has no tracks to play.")
} else {
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
poolMessages.trySend(
snackbarMessages.trySend(
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
)
}
@@ -328,52 +321,66 @@ class HomeViewModel @Inject constructor(
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
playPlaylistShuffled(playlist, playlistsRepository, player) {
poolMessages.trySend(it)
snackbarMessages.trySend(it)
}
}.join()
}
/**
* Pulls both /home/index and the playlists list. Returns the Job
* for the combined refresh so a pull-to-refresh wrapper can await
* actual completion before hiding the indicator.
* Pulls /home/index, the playlists list and the system-playlist
* status. Returns true when the load-bearing /home/index pull
* succeeded — the veil controller retries on false and reports the
* outcome, so this must report failure rather than swallow it.
*/
fun refresh(): Job = viewModelScope.launch {
refreshError.value = null
val home = launch {
// /home/index is the load-bearing pull: its failure drives the
// empty-cache Error state. A failure over a populated cache
// stays silent — cached sections beat a full-screen error.
private suspend fun runRefresh(): Boolean = coroutineScope {
// /home/index is the load-bearing pull: its failure drives the
// empty-cache Error state. A failure over a populated cache
// stays silent — cached sections beat a full-screen error.
//
// Cleared on success, NOT at the start of each attempt: with the
// veil's retries, clearing up front made a failing cold start
// flash the "Welcome to Minstrel" empty state (empty cache + no
// error reads as Empty) between one attempt and the next.
val home = async {
runCatching { homeRepository.refreshIndex() }
.onSuccess { refreshError.value = null }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
.isSuccess
}
val lists = launch { runCatching { playlistsRepository.refreshList() } }
val status = launch {
runCatching { homeRepository.getSystemPlaylistsStatus() }
.onSuccess { systemStatusInternal.value = it }
}
home.join()
lists.join()
status.join()
home.await()
}
/**
* Automatic background refresh with the "Updating your mixes…" veil
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
* paths where Home is already on screen. Holds the veil through the
* pull plus a short settle so per-tile hydration lands behind it, then
* lets it wipe off. Overlapping automatic refreshes are rare enough
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
* The Error state's explicit Retry button. User-initiated, so it gets
* the controller's retries and reports its outcome; over an empty cache
* there's no content to protect, so no veil goes up.
*/
private fun refreshBehindVeil() {
viewModelScope.launch {
updatingInternal.value = true
try {
refresh().join()
delay(VEIL_SETTLE_MS)
} finally {
updatingInternal.value = false
}
fun retry() = veil.request(userInitiated = true)
/**
* Manual pull-to-refresh. Goes behind the veil like every other refresh
* (operator call, 2026-07-31: the churn a pull causes is identical to
* the automatic paths, and a small spinner didn't hide it).
*
* Suspends until the veil has taken over OR the session has finished,
* so the pull indicator hands off to exactly one successor: the veil if
* content changed, the "Already up to date" snackbar if it didn't. The
* timeout is only a backstop against a session that outlives it.
*/
suspend fun refreshFromPull() {
val before = veil.finishedSessions.value
veil.request(userInitiated = true)
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
veiled || finished != before
}.first { it }
}
}
@@ -425,6 +432,124 @@ class HomeViewModel @Inject constructor(
else -> UiState.Empty
}
}
// ─── Updating veil ───────────────────────────────────────────────
// Declared after uiState: these initialisers read it, and Kotlin runs
// property initialisers and init blocks in declaration order.
/**
* What the veil watches to decide Home has stopped moving: the whole
* rendered state, plus how many covers are still loading.
*
* [UiState.Success] wraps a [HomeSections] data class, so any visible
* change — a section swapping ids, one tile hydrating from skeleton to
* album — changes this value and re-arms the veil's quiet window.
*
* Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
* on-miss fetch soft-fails keeps a null value indefinitely
* ([MetadataProvider] swallows those errors), so treating "no
* skeletons left" as the settle condition would pin the veil to its
* hard ceiling on every refresh. They're covered by the content key
* instead: each tile that lands re-arms the window, and once they stop
* landing the screen is genuinely still.
*/
private val settleSignal: Flow<VeilSettleState> =
combine(uiState, artTracker.inFlight) { state, artInFlight ->
VeilSettleState(
contentKey = state,
hasContent = state is UiState.Success,
quiescent = artInFlight == 0,
)
}
private val veil = UpdateVeilController(
scope = viewModelScope,
settleSignal = settleSignal,
shouldVeil = {
// Only worth hiding churn when there's already content to
// hide. A cold load over an empty cache keeps its skeleton —
// veiling that would replace a useful affordance with an
// opaque panel. `hasCachedIndex` is the honest check: uiState
// still reads Loading until the screen subscribes, so on a
// process restore over a warm cache it would say "no content"
// right before the cache emits.
uiState.value is UiState.Success || homeRepository.hasCachedIndex()
},
onSessionEnd = ::reportRefreshOutcome,
work = ::runRefresh,
)
/**
* Tells the user how a refresh *they asked for* went, in the one case
* the veil can't: when nothing changed there's no veil to see, and a
* pull that produces no visible response at all reads as broken.
*
* Only user-initiated sessions say anything. The same outcome from a
* background check — the initial load, the 03:00 rebuild, a reconnect —
* is noise, and "Already up to date" on every launch would be worse
* than silence (operator's call, 2026-07-31).
*/
private fun reportRefreshOutcome(result: VeilSessionResult) {
if (!result.userInitiated) return
when (result.outcome) {
// The veil was the feedback.
VeilOutcome.CHANGED -> return
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
}
}
/**
* True while the "Updating your mixes…" veil should be raised.
*
* Raised only when a refresh actually changes what's on screen, and then
* held until Home settles — tiles hydrated, artwork loaded — instead of
* for a fixed delay after the network pull returns (issue #2327). A
* refresh that returns what's already cached shows no veil at all;
* [reportRefreshOutcome] tells the user instead, if they asked.
*/
val isUpdating: StateFlow<Boolean> = veil.visible
init {
// Every refresh path goes through the controller, which decides
// per session whether to raise the veil. That includes the initial
// load: over a warm cache it's a full re-pull that churns every
// section, and it used to run completely unveiled.
veil.request()
// Screen-level auto-recovery (issue #1245): a Home that failed to
// load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up.
// This is also the recovery that keeps trying after the veil has
// given up and lowered; the controller sets no latch against it.
viewModelScope.launch {
networkStatus.recoveries().collect { veil.request() }
}
// Server-side changes that rewrite what Home renders (#968 and
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
// web SSE consumer.
viewModelScope.launch {
eventsStream.events
.filter { it.kind in VEILED_EVENT_KINDS }
.collect { veil.request() }
}
}
private companion object {
/**
* Events that change what Home shows. `playlist.system_rebuilt`
* is the 03:00 daily rebuild; the other `playlist.*` kinds move
* the Playlists and Songs-like rows; `scan.run_finished` changes
* Recently added (and Home never reacted to it at all before).
*/
private val VEILED_EVENT_KINDS = setOf(
"playlist.system_rebuilt",
"playlist.created",
"playlist.updated",
"playlist.deleted",
"playlist.tracks_changed",
"scan.run_finished",
)
}
}
// ─── Screen ──────────────────────────────────────────────────────────
@@ -454,14 +579,20 @@ fun HomeScreen(
val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
onRefresh = { viewModel.refreshFromPull() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
Box(Modifier.fillMaxSize()) {
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
// Automatic-refresh veil: the daily rebuild / reconnect
// churn hides behind an "Updating your mixes…" wipe. Manual
// pull owns the PullToRefreshBox spinner instead.
// Every cover below reports its load state to the tracker,
// so the veil can wait for artwork instead of guessing.
CompositionLocalProvider(
LocalArtSettleTracker provides viewModel.artTracker,
) {
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
}
// Refresh veil: rebuild / reconnect / pull / event churn all
// hide behind an "Updating your mixes…" wipe that stays up
// until the screen has actually stopped moving.
UpdatingVeil(visible = updating)
}
}
@@ -499,7 +630,7 @@ private fun HomeStateCrossfade(
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.refresh() },
onRetry = { viewModel.retry() },
)
is UiState.Success -> HomeSuccessContent(
sections = s.data,
@@ -0,0 +1,19 @@
package com.fabledsword.minstrel.models
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
data class AdminTagSourceRef(
val id: String,
val displayName: String,
val requiresApiKey: Boolean,
val supports: List<String>,
val enabled: Boolean,
val apiKeySet: Boolean,
val testable: Boolean,
)
/** Result of a provider "test connection" call. */
data class TagSourceTestResult(
val ok: Boolean,
val durationMs: Long = 0,
val error: String = "",
)
@@ -0,0 +1,35 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
* Every field defaults defensively so a missing JSON key never crashes
* deserialization. `versionBumped` is present only on the PATCH response.
*/
@Serializable
data class AdminTagSourceWire(
val id: String = "",
@SerialName("display_name") val displayName: String = "",
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
val supports: List<String> = emptyList(),
val enabled: Boolean = false,
@SerialName("api_key_set") val apiKeySet: Boolean = false,
@SerialName("display_order") val displayOrder: Int = 0,
val testable: Boolean = false,
@SerialName("version_bumped") val versionBumped: Boolean = false,
)
@Serializable
data class AdminTagSourcesListWire(
val providers: List<AdminTagSourceWire> = emptyList(),
@SerialName("sources_version") val sourcesVersion: Int = 0,
)
@Serializable
data class TestTagSourceWire(
val ok: Boolean = false,
@SerialName("duration_ms") val durationMs: Long = 0,
val error: String = "",
)
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
)
@Serializable
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
val at: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long,
val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
)
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
import com.fabledsword.minstrel.auth.ui.LoginScreen
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
}
}
}
composable<AdminTagSources> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
AdminTagSourcesScreen(navController = navController)
}
}
}
}
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
@Serializable data object AdminRequests
@Serializable data object AdminQuarantine
@Serializable data object AdminUsers
@Serializable data object AdminTagSources
// ── Outside-shell full-screen routes ──────────────────────────────────
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import java.io.IOException
import kotlin.math.abs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -58,8 +59,10 @@ import timber.log.Timber
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop syncs the local cursor by comparing the
* 1-based Track index from GetPositionInfo.
* advance natively. PollLoop keeps the local cursor aligned to the
* track the renderer is actually playing, matched by track identity
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
* index drift -- see [syncLocalCursorToRemote].
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
@@ -339,6 +342,23 @@ class MinstrelForwardingPlayer(
}
}
// The system media notification / lock-screen / Android Auto / Wear 'next'
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
// base ForwardingPlayer forwards these to the paused local delegate -- so
// the notification next/prev only nudged the local cursor (which the poll
// then re-synced back to Sonos), reading as dead buttons while casting.
// Route them through the same Sonos path as the media-item variants when a
// UPnP route is engaged; plain local playback keeps the default behaviour.
override fun seekToNext() {
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
}
override fun seekToPrevious() {
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
}
override fun getCurrentPosition(): Long =
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
/**
* One poll tick: read position + transport state from Sonos, apply to
* [remoteState], and forward-sync the local cursor to Sonos's Track
* index when not in queue load.
*
* Cursor sync is gated on `holder.target == null` (= not loading)
* because during load Sonos reports Track=1 while we're still
* appending, and syncing would race the SetAV+Seek that lands
* after. Outside load, forward sync catches Sonos auto-advances
* (queue end-of-track), Sonos-app driven Next presses, and any
* drift after a brief poll-failure burst that didn't trip the
* drop threshold. Forward-only because a Next override we just
* issued can race with a poll still reporting the prior Track --
* the next poll catches up safely.
* [remoteState], and sync the local cursor to the track the renderer is
* actually playing (see [syncLocalCursorToRemote]).
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
trackUri = info.trackUri,
trackNumber = info.track,
)
maybeSyncLocalCursor(info.track)
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
TransportState.PLAYING -> {
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
notifyRemoteStateChanged()
}
private fun maybeSyncLocalCursor(sonosTrack: Int) {
if (holder.target.value != null) return
if (sonosTrack <= 0) return
val sonosIdx = sonosTrack - 1
/**
* Align the paused local delegate cursor to the track the renderer is
* actually playing, so the un-overridden current-item getters
* (getCurrentMediaItem/Index -- what both the in-app player UI via
* PlayerController.onEvents AND the MediaSession notification read) always
* resolve to that track. This is THE single source of truth for "what's
* playing" during a cast.
*
* Identity-first: match the track-id embedded in the renderer's current URI
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
* can never park the cursor on a stale (pre-cast) track. Falls back to the
* 1-based Sonos Track index only when the URI can't be resolved to a queue
* item. Unlike the old forward-only sync this moves the cursor in BOTH
* directions -- a renderer Previous / re-cast to a lower track pulls it back
* too. Runs on the application looper (delegate access contract).
*
* Suppressed while:
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
* (re)uploaded, so its Track index is meaningless; and
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
* the override already advanced the delegate optimistically, and Sonos
* still reports the OLD track for a beat, so syncing now would undo the
* user's press. The pending deadline is honoured directly so a missed
* clear can't wedge the sync.
*/
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
if (isLoadingUpnp()) return
val pendingDeadline = remoteState.pendingTransportDeadlineMs
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
handler.post {
val localIdx = delegate.currentMediaItemIndex
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
if (target != delegate.currentMediaItemIndex) {
Timber.w(
"UPnP cursor catch-up: local=%d -> sonos=%d",
localIdx, sonosIdx,
"UPnP cursor sync: local=%d -> %d",
delegate.currentMediaItemIndex, target,
)
delegate.seekTo(sonosIdx, 0L)
delegate.seekTo(target, 0L)
}
}
}
/**
* Most-authoritative -> most-degraded (project rule #48): the track-id
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
* index; else null (leave the cursor put rather than surface a wrong track).
* Must run on the application looper -- reads the delegate timeline.
*/
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
}
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
}
/**
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
* occurrence nearest [preferNear] so a track that appears twice in the queue
* doesn't snap the cursor across the queue. null if the id isn't present.
*/
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
var best: Int? = null
var bestDist = Int.MAX_VALUE
for (i in 0 until delegate.mediaItemCount) {
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
val dist = abs(i - preferNear)
if (dist < bestDist) {
best = i
bestDist = dist
}
}
}
return best
}
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
private fun trackIdFromStreamUri(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private companion object {
// Path segment that precedes the track-id in a stream URI
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
// back to a delegate MediaItem by identity.
const val TRACKS_PATH_MARKER = "/api/tracks/"
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
trackId = trackId,
clientId = cid,
source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
),
)
if (curTrackId == trackId) {
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
atIso = startedAt.toString(),
durationPlayedMs = durationPlayedMs,
source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
),
)
}
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
// ── Lifecycle: durable-close on app background / detach ────────
companion object {
// #1551: Android is a mobile client. Refine to tablet/tv via device
// configuration later if the metrics trend view shows it matters.
private const val DEVICE_CLASS = "mobile"
}
override fun onStop(owner: LifecycleOwner) {
// App backgrounded. If the tracked play is still PLAYING, leave it
// alone: the foreground media service keeps the process — and this
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
controller.addMediaItem(track.toMediaItem(source = null))
}
/**
* Reorder the queue: move the item at [from] to [to], keeping the domain
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
* onEvents → uiState reflects the new order (and the still-playing item's
* index). No-op on bad indices or a no-move.
*/
fun moveInQueue(from: Int, to: Int) {
val controller = mediaController ?: return
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
controller.moveMediaItem(from, to)
}
/**
* Remove the queue item at [index]. When it's the currently-playing item
* Media3 advances to the next automatically. No-op on a bad index.
*/
fun removeFromQueue(index: Int) {
val controller = mediaController ?: return
if (index !in queueRefs.indices) return
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
controller.removeMediaItem(index)
}
/** Empty the queue and stop playback. */
fun clearQueue() {
val controller = mediaController ?: return
queueRefs = emptyList()
controller.clearMediaItems()
}
/**
* Seed a fresh radio queue from [trackId]. The `source` tag is
* "radio:<id>" so the server-side rotation reporter can
@@ -598,18 +629,19 @@ class PlayerController @Inject constructor(
}
/**
* One position-polling tick. Owns track-change detection too: when UPnP
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
* uiState.queueIndex stuck on the old track even after Sonos has
* advanced. So the tick reads Sonos's reported Track as the source of
* truth, rebuilds the index/title fields itself, and force-syncs the
* wrapped player as defense in depth.
* One position-polling tick. Patches ONLY the smoothly-changing transport
* fields (position / duration / play-pause / buffer) via `.copy()`. The
* queue index + current track are owned solely by [onEvents], which reads
* the authoritative delegate cursor: during a cast,
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
* the track the renderer is actually playing (matched by identity), so there
* is exactly one writer of "what's playing" and a stale tick can never
* revert it to the old track. (Previously the tick was a second index writer
* that fought onEvents — the flicker-to-stale-track bug.)
*/
private fun tickPositionPoll(controller: MediaController) {
val upnpActive = activeUpnpHolder.active.value != null
resolvePendingTransport(controller, upnpActive)
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
val effectiveIsPlaying =
if (upnpActive) remoteState.isPlaying else controller.isPlaying
val effectivePosition = if (upnpActive) {
@@ -617,27 +649,29 @@ class PlayerController @Inject constructor(
} else {
controller.currentPosition
}
val desiredIdx = desiredQueueIndex(controller, upnpActive)
val idx = controller.currentMediaItemIndex
val current = uiStateInternal.value
val newPos = effectivePosition.coerceAtLeast(0)
val newDur = effectiveDuration(
upnpActive,
remoteState.durationMs,
controller.duration,
desiredIdx = desiredIdx,
controllerIdx = controller.currentMediaItemIndex,
desiredIdx = idx,
controllerIdx = idx,
)
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
// Track adjustments are forward-only AND suppressed while a user
// transport press is pending Sonos confirmation. Together those keep
// either direction of user input from being undone by a stale poll.
val trackChanged = !pendingTransport &&
desiredIdx > current.queueIndex &&
desiredIdx in queueRefs.indices
publishTickIfChanged(
current, trackChanged, desiredIdx,
effectiveIsPlaying, newPos, newDur, newBuf,
)
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur ||
current.bufferedPositionMs != newBuf
if (somethingChanged) {
uiStateInternal.value = current.copy(
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
}
}
/**
@@ -654,48 +688,6 @@ class PlayerController @Inject constructor(
}
}
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
private fun publishTickIfChanged(
current: PlayerUiState,
trackChanged: Boolean,
desiredIdx: Int,
effectiveIsPlaying: Boolean,
newPos: Long,
newDur: Long,
newBuf: Long,
) {
val somethingChanged = trackChanged ||
current.isPlaying != effectiveIsPlaying ||
current.positionMs != newPos ||
current.durationMs != newDur
if (!somethingChanged) return
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
uiStateInternal.value = current.copy(
currentTrack = newTrack,
queueIndex = newIdx,
isPlaying = effectiveIsPlaying,
positionMs = newPos,
durationMs = newDur,
bufferedPositionMs = newBuf,
)
// Intentionally do NOT call controller.seekTo here. That would route
// through MinstrelForwardingPlayer's seekTo override and re-issue
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
// start of the same track it's already playing, restarting the song.
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
// delegate.seekTo (which bypasses the override). If it lags briefly,
// the next pollOnce catches up; the uiState above already reflects
// Sonos's truth for the user.
}
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
if (upnpActive) {
(remoteState.trackNumber - 1).coerceAtLeast(0)
} else {
controller.currentMediaItemIndex
}
// ── Remote position interpolation state ──────────────────────────────
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
@@ -1,11 +1,16 @@
package com.fabledsword.minstrel.player.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
@@ -20,13 +25,29 @@ import javax.inject.Inject
* stub-test for ViewModel-level logic when it grows).
*/
@HiltViewModel
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
class PlayerViewModel @Inject constructor(
private val controller: PlayerController,
private val likes: LikesRepository,
) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
/**
* Reactive set of liked track ids — the set-based pattern the queue
* (and playlist/album/artist detail) uses to color each row's
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
* per-row Flow subscription.
*/
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTrackIds()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = emptySet(),
)
fun play() = controller.play()
fun pause() = controller.pause()
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
fun seekToIndex(index: Int) = controller.seekToIndex(index)
fun toggleShuffle() = controller.toggleShuffle()
fun cycleRepeat() = controller.cycleRepeat()
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
fun clearQueue() = controller.clearQueue()
fun toggleLikeTrack(trackId: String) {
val desired = trackId !in likedTrackIds.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
}
}
}
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@@ -1,17 +1,25 @@
package com.fabledsword.minstrel.player.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -20,22 +28,43 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowDown
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.GripVertical
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Trash2
import com.composables.icons.lucide.Volume2
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.ServerImage
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -44,16 +73,35 @@ fun QueueScreen(
viewModel: PlayerViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Queue") },
title = {
Column {
Text("Queue")
if (state.queue.isNotEmpty()) {
Text(
text = queueSummary(state.queue),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
if (state.queue.isNotEmpty()) {
IconButton(onClick = viewModel::clearQueue) {
Icon(Lucide.Trash2, contentDescription = "Clear queue")
}
}
},
)
},
) { inner ->
@@ -67,33 +115,109 @@ fun QueueScreen(
QueueList(
tracks = state.queue,
currentIndex = state.queueIndex,
likedTrackIds = likedTrackIds,
onJumpTo = viewModel::seekToIndex,
onToggleLike = viewModel::toggleLikeTrack,
onMove = viewModel::moveInQueue,
onRemove = viewModel::removeFromQueue,
)
}
}
}
}
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
@Composable
private fun QueueList(
tracks: List<TrackRef>,
currentIndex: Int,
likedTrackIds: Set<String>,
onJumpTo: (Int) -> Unit,
onToggleLike: (String) -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
isCurrent = index == currentIndex,
onClick = { onJumpTo(index) },
)
HorizontalDivider()
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
)
val scope = rememberCoroutineScope()
// Follow the now-playing row as the track auto-advances, but only while it's
// near the visible window — if the user has scrolled away to browse, leave
// them there (the pill offers the way back). Parity with the web queue.
LaunchedEffect(currentIndex) {
if (currentIndex < 0) return@LaunchedEffect
val visible = listState.layoutInfo.visibleItemsInfo
val first = visible.firstOrNull()?.index ?: 0
val last = visible.lastOrNull()?.index ?: 0
if (currentIndex in (first - 1)..(last + 1)) {
listState.animateScrollToItem(currentIndex)
}
}
val currentVisible by remember {
derivedStateOf {
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
}
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
QueueRow(
track = track,
index = index,
queueSize = tracks.size,
isCurrent = index == currentIndex,
liked = track.id in likedTrackIds,
onClick = { onJumpTo(index) },
onToggleLike = { onToggleLike(track.id) },
onRemove = { onRemove(index) },
onMove = onMove,
)
HorizontalDivider()
}
}
JumpToCurrentPill(
visible = currentIndex >= 0 && !currentVisible,
onClick = {
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
},
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
)
}
}
@Composable
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
private fun JumpToCurrentPill(
visible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(visible = visible, modifier = modifier) {
FilledTonalButton(onClick = onClick) {
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Jump to current")
}
}
}
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
@Composable
private fun QueueRow(
track: TrackRef,
index: Int,
queueSize: Int,
isCurrent: Boolean,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
onRemove: () -> Unit,
onMove: (Int, Int) -> Unit,
) {
var dragOffsetY by remember { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableIntStateOf(0) }
val highlight = if (isCurrent) {
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
} else {
@@ -102,12 +226,23 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { rowHeightPx = it.height }
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
.graphicsLayer { translationY = dragOffsetY }
.background(highlight)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
DragHandle(
index = index,
queueSize = queueSize,
rowHeightPx = rowHeightPx,
onOffsetChange = { dragOffsetY = it },
onMove = onMove,
)
QueueRowThumbnail(track = track)
if (isCurrent) {
Icon(
Lucide.Volume2,
@@ -115,26 +250,7 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
tint = MaterialTheme.colorScheme.primary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = queueSubtitle(track)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
if (track.durationSec > 0) {
Text(
text = formatDuration(track.durationSec),
@@ -142,6 +258,95 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LikeButton(liked = liked, onToggle = onToggleLike)
IconButton(onClick = onRemove) {
Icon(Lucide.X, contentDescription = "Remove from queue")
}
}
}
@Composable
private fun DragHandle(
index: Int,
queueSize: Int,
rowHeightPx: Int,
onOffsetChange: (Float) -> Unit,
onMove: (Int, Int) -> Unit,
) {
// Mirrors the web queue: the row follows the finger during a drag, then on
// release we translate the accumulated offset into a row delta and reorder.
var offset by remember { mutableFloatStateOf(0f) }
Icon(
Lucide.GripVertical,
contentDescription = "Reorder track",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
detectDragGestures(
onDrag = { change, dragAmount ->
change.consume()
offset += dragAmount.y
onOffsetChange(offset)
},
onDragEnd = {
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
val target = (index + delta).coerceIn(0, queueSize - 1)
if (target != index) onMove(index, target)
offset = 0f
onOffsetChange(0f)
},
onDragCancel = {
offset = 0f
onOffsetChange(0f)
},
)
},
)
}
@Composable
private fun QueueRowThumbnail(track: TrackRef) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
ServerImage(
url = track.coverUrl,
contentDescription = null,
modifier = Modifier.size(48.dp),
) {
Icon(
Lucide.Music,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = queueSubtitle(track)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -150,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
.filter { it.isNotEmpty() }
.joinToString(" · ")
/** "N tracks · 12 min" header summary. */
private fun queueSummary(tracks: List<TrackRef>): String {
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
val length = if (minutes >= MINUTES_PER_HOUR) {
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
} else {
"$minutes min"
}
val noun = if (tracks.size == 1) "track" else "tracks"
return "${tracks.size} $noun · $length"
}
private const val HIGHLIGHT_ALPHA = 0.12f
private const val SECONDS_PER_MINUTE = 60
private const val MINUTES_PER_HOUR = 60
@@ -0,0 +1,315 @@
package com.fabledsword.minstrel.shared
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean
// Once raised, the veil stays up at least this long. Without a floor a
// no-op refresh wipes on and straight back off, which reads as a glitch.
private const val DEFAULT_MIN_HOLD_MS = 900L
// The screen must stop changing for this long before the veil lowers.
// Every content change re-arms it, so a refresh that lands in stages
// (index → tile hydration → artwork) holds the veil across all of them.
private const val DEFAULT_QUIET_MS = 700L
// Hard ceiling on visibility. A refresh that never settles must not
// strand the user behind an opaque veil; the work itself is NOT capped.
private const val DEFAULT_MAX_HOLD_MS = 12_000L
// Attempts per session. Retrying behind the veil is the point: a pull
// that fails on the first try gets another go before the user sees
// anything, instead of the veil wiping off over unchanged content.
private const val DEFAULT_ATTEMPTS = 3
private const val DEFAULT_RETRY_BACKOFF_MS = 600L
/** Tunables for [UpdateVeilController]; defaults are the Home values. */
data class VeilTimings(
val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
val quietMs: Long = DEFAULT_QUIET_MS,
val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
val attempts: Int = DEFAULT_ATTEMPTS,
val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
)
/**
* A snapshot of everything that visibly moves on the veiled screen.
*
* @param contentKey any value whose equality tracks what's rendered — a
* change means the screen moved, and re-arms the quiet timer.
* @param hasContent true when real content (not a skeleton or an empty
* state) is on screen. The veil waits for this before raising: there's
* nothing to hide until there's something to hide.
* @param quiescent false while something is still landing (artwork
* loading, tiles hydrating). The veil will not lower until this is
* true, up to [VeilTimings.maxHoldMs].
*/
data class VeilSettleState(
val contentKey: Any?,
val hasContent: Boolean,
val quiescent: Boolean,
)
/** What a finished session did, so callers can report it if they want. */
enum class VeilOutcome {
/** Content changed, and the veil covered the churn. */
CHANGED,
/** The refresh worked, but nothing on screen moved — already current. */
UNCHANGED,
/** Every attempt failed. */
FAILED,
}
/**
* One session's result, plus whether a user explicitly asked for it.
*
* [userInitiated] is what lets a caller tell feedback from noise: a user
* who pulled to refresh is owed an answer even when the answer is "nothing
* changed", while the same outcome from a background check is noise.
*/
data class VeilSessionResult(
val outcome: VeilOutcome,
val userInitiated: Boolean,
)
/**
* Drives an "updating" overlay from *observed content change and settling*
* rather than from a fixed delay.
*
* The problem this replaces: a veil held for `refresh().join() + 500ms`
* lowers while the screen is still moving, because finishing the network
* pull is nowhere near the end of the visible work — the pull writes id
* lists, then tiles hydrate one by one, then artwork loads. And a plain
* `isUpdating` Boolean set in a `finally` gets cleared by whichever of
* two overlapping refreshes finishes first, wiping the veil off mid-update
* (issue #2327).
*
* So instead: run [work], raise only if the content actually changes, then
* hold until [settleSignal] reports the screen has stopped changing for
* [VeilTimings.quietMs] AND is quiescent — bounded below by
* [VeilTimings.minHoldMs] so it can never flash, and above by
* [VeilTimings.maxHoldMs] so it can never strand.
*
* The raise is deliberately *reactive*: a refresh that returns what's
* already on screen — the common case on a launch over a warm cache —
* raises nothing at all, because a veil over an unchanged screen hides
* nothing and only delays first paint. The cost is that the veil arrives
* one emission after the change, so a single atomic content swap shows
* through; everything messier that follows it (tile hydration, then
* artwork) still lands behind the veil.
*
* Overlapping triggers extend the running session instead of racing it,
* so the veil stays up continuously rather than lowering and re-raising.
*
* Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
* behind the veil, and if they all fail the veil simply wipes off over the
* cached content. Giving up ends only *this* session — it sets no latch and
* blocks nothing, so the caller's own recovery paths (reconnect re-pull,
* freshness sweeps, the next event, a manual pull) keep retrying afterwards
* exactly as before. Callers that want to surface a failure can do it from
* [onSessionEnd] instead.
*
* @param work one refresh attempt; returns true when it succeeded.
* @param shouldVeil sampled at session start — "is there cached content
* this refresh is about to overwrite?". False means a cold load, where
* a skeleton is the right affordance, and the work runs unveiled.
* @param onSessionEnd called once per finished session, on the controller's
* coroutine. Use it for user-facing feedback the veil itself can't give.
*/
class UpdateVeilController(
private val scope: CoroutineScope,
private val settleSignal: Flow<VeilSettleState>,
private val shouldVeil: suspend () -> Boolean,
private val timings: VeilTimings = VeilTimings(),
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
private val work: suspend () -> Boolean,
) {
private val visibleInternal = MutableStateFlow(false)
/** True while the veil should be drawn over the screen. */
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
private val finishedInternal = MutableStateFlow(0)
/**
* Increments as each session ends. Lets a caller wait for "this
* refresh is done" without knowing whether a veil ever went up —
* a pull-to-refresh indicator needs exactly that, since an unchanged
* refresh never raises one.
*/
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
// Conflated: a burst of triggers (reconnect + rebuild event arriving
// together) collapses into one follow-up pass, not a queue of them.
private val requests = Channel<Unit>(Channel.CONFLATED)
// Sticky across a conflated burst: conflation drops the older token, so
// the "a user asked for this" bit can't ride on it. If ANY coalesced
// trigger was the user's, the session still owes them an answer.
private val userAsked = AtomicBoolean(false)
init {
// One consumer, so sessions are serialised by construction: two
// triggers can never each own a piece of the veil's state.
scope.launch {
while (true) {
requests.receive()
runSession()
}
}
}
/**
* Ask for a refresh. Safe to call from any trigger at any rate —
* calls arriving during a session extend it rather than starting a
* competing one.
*
* @param userInitiated true when a person explicitly asked (pull to
* refresh, a Retry button), which is what [VeilSessionResult] carries
* through to [onSessionEnd].
*/
fun request(userInitiated: Boolean = false) {
if (userInitiated) userAsked.set(true)
requests.trySend(Unit)
}
private suspend fun runSession() {
// Sampled at both ends of the work: a trigger folded in mid-session
// (see [drainWork]) may have been the user's, and they're still owed
// an answer for it.
val askedAtStart = userAsked.getAndSet(false)
if (!shouldVeil()) {
// Cold load: the skeleton is the right affordance, so no veil.
// Succeeding here did change the screen — from nothing to
// something — so it reports CHANGED, never "already up to date".
val ok = drainWork()
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
return
}
val raised = CompletableDeferred<Unit>()
val raiser = scope.launch { raiseWhenContentChanges(raised) }
// Floor and ceiling are measured from the raise, not the request,
// so a late raise still gets its full no-flash minimum.
val floor = scope.launch {
raised.await()
delay(timings.minHoldMs)
}
val ceiling = scope.launch {
raised.await()
delay(timings.maxHoldMs)
visibleInternal.value = false
}
var succeeded = false
try {
succeeded = drainWork()
// Always wait for the settle, never conditionally on `visible`:
// work that finishes without suspending would otherwise reach
// here before the raiser has been dispatched, tear the session
// down, and leave the churn uncovered. This wait is also what
// makes `raised.isCompleted` below a trustworthy "did anything
// change?" — a change landing just after the pull returns still
// gets seen.
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
// Honour the no-flash minimum before lowering. Deliberately in
// the try and not the finally: on cancellation the scope is
// going away and nothing will render the veil, so the floor is
// pointless there — and a finally that suspends is a finally
// that can resist teardown.
if (raised.isCompleted) floor.join()
} finally {
raiser.cancel()
ceiling.cancel()
floor.cancel()
visibleInternal.value = false
finish(
succeeded = succeeded,
changed = raised.isCompleted,
userInitiated = askedAtStart,
)
}
}
/**
* Raises the veil the moment the screen's content differs from what was
* already on it — and never, if this refresh turns out to be a no-op.
*
* The baseline is the first state that HAS content, not simply the first
* state: over a warm cache the cached rows paint a moment after the
* session starts, and treating that first paint as "a change" would veil
* every launch, which is the whole thing this avoids.
*/
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
val baseline = settleSignal.first { it.hasContent }
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
visibleInternal.value = true
raised.complete(Unit)
}
private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
val outcome = when {
!succeeded -> VeilOutcome.FAILED
changed -> VeilOutcome.CHANGED
else -> VeilOutcome.UNCHANGED
}
onSessionEnd(
VeilSessionResult(
outcome = outcome,
// Fold in a mid-session request from the user.
userInitiated = userInitiated || userAsked.getAndSet(false),
),
)
finishedInternal.update { it + 1 }
}
/** True when the refresh eventually succeeded. */
private suspend fun drainWork(): Boolean {
var succeeded: Boolean
do {
succeeded = runWorkWithRetries()
// A trigger that arrived mid-session gets folded into this one.
} while (requests.tryReceive().isSuccess)
return succeeded
}
private suspend fun runWorkWithRetries(): Boolean {
repeat(timings.attempts) { attempt ->
if (work()) return true
if (attempt < timings.attempts - 1) {
delay(timings.retryBackoffMs * (attempt + 1))
}
}
return false
}
/**
* Suspends until the screen has been unchanged for
* [VeilTimings.quietMs] and reports itself quiescent.
*
* `debounce` is what makes this hold across a staged update: every
* change restarts the window, so the veil lowers only once emissions
* actually stop. `first { quiescent }` then rejects a quiet-but-
* still-loading moment and waits for the next lull.
*/
@OptIn(FlowPreview::class)
private suspend fun awaitSettled() {
settleSignal
.distinctUntilChanged()
.debounce(timings.quietMs)
.first { it.quiescent }
}
}
@@ -0,0 +1,60 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.runtime.Stable
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Counts the cover-art loads that are currently in flight, so a
* screen-level overlay can wait for the artwork to actually land instead
* of guessing with a fixed delay.
*
* Artwork is the most visible pop-in on Home: a tile can be fully
* hydrated (title, artist, counts all present) and still snap its cover
* in a second later, which is exactly the churn the "Updating your
* mixes…" veil exists to hide. The refresh coroutine can't see that —
* it finishes long before Coil does — so the composition reports it
* upward here instead.
*
* [ServerImage] reports into whatever tracker it finds in
* [LocalArtSettleTracker], which means every art surface in the app
* participates for free. Only *composed* images are counted, so a
* LazyRow's off-screen tiles are correctly ignored — the count tracks
* the pop-in a user can actually see.
*
* Provide one per screen that needs it (typically owned by the
* screen's ViewModel so its refresh logic can read [inFlight]):
*
* CompositionLocalProvider(LocalArtSettleTracker provides vm.artTracker) { ... }
*/
@Stable
class ArtSettleTracker {
private val inFlightInternal = MutableStateFlow(0)
/**
* How many on-screen images are still loading. Zero means the
* artwork has settled — every composed cover has either drawn or
* failed to a fallback.
*/
val inFlight: StateFlow<Int> = inFlightInternal.asStateFlow()
fun begin() {
inFlightInternal.update { it + 1 }
}
fun end() {
// Floor at zero: a decrement can outlive its increment when a
// tile is disposed mid-load and the count must not go negative
// and wedge "settled" off forever.
inFlightInternal.update { (it - 1).coerceAtLeast(0) }
}
}
/**
* The tracker [ServerImage] reports load state to, or null on screens
* that don't care (the default) — reporting is then a no-op.
*/
val LocalArtSettleTracker = staticCompositionLocalOf<ArtSettleTracker?> { null }
@@ -1,25 +1,40 @@
package com.fabledsword.minstrel.shared.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import com.fabledsword.minstrel.shared.resolveServerUrl
// The fallback fades out as the artwork crossfades in (Coil's crossfade is
// configured globally on the ImageLoader in MinstrelApplication). Matching
// durations makes the swap read as one cross-dissolve; without the fade the
// placeholder icon vanished a frame before the cover appeared, which is the
// "art popping in" the Home veil exists to hide (issue #2327).
private const val FALLBACK_FADE_MS = 220
/**
* Renders a server-hosted image, resolving relative URLs centrally so
* every cover surface loads consistently. Shows [fallback] when the URL
* is blank/unresolvable, while the image is still loading, and when the
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
* which the "You might like" row hits often).
*
* In-flight loads are reported to [LocalArtSettleTracker] when a screen
* provides one, so a screen-level overlay can wait for artwork to land
* instead of guessing with a fixed delay.
*/
@Composable
fun ServerImage(
@@ -40,6 +55,23 @@ fun ServerImage(
var state by remember(resolved) {
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
}
// Empty counts as loading: it's the pre-request state, so treating it
// as settled would let a screen overlay lower before Coil even starts.
val loading = state is AsyncImagePainter.State.Empty ||
state is AsyncImagePainter.State.Loading
val tracker = LocalArtSettleTracker.current
DisposableEffect(tracker, loading) {
if (loading) tracker?.begin()
// Balanced by construction: the effect re-runs when `loading` flips
// (decrement, then no re-increment) and disposes when a tile leaves
// the composition mid-load (scrolled away).
onDispose { if (loading) tracker?.end() }
}
val fallbackAlpha by animateFloatAsState(
targetValue = if (loading || state is AsyncImagePainter.State.Error) 1f else 0f,
animationSpec = tween(FALLBACK_FADE_MS),
label = "art-fallback",
)
Box(modifier = modifier, contentAlignment = Alignment.Center) {
AsyncImage(
model = resolved,
@@ -48,10 +80,10 @@ fun ServerImage(
contentScale = contentScale,
onState = { state = it },
)
if (state is AsyncImagePainter.State.Loading ||
state is AsyncImagePainter.State.Error
) {
fallback()
if (fallbackAlpha > 0f) {
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
fallback()
}
}
}
}
@@ -0,0 +1,314 @@
package com.fabledsword.minstrel.shared
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
private const val WORK_MS = 1_000L
private const val CHURN_ROUNDS = 5
private const val ART_IN_FLIGHT = 3
private const val SUCCEED_ON_ATTEMPT = 3
private const val QUIET_WINDOWS_TO_OUTLAST = 3
// Time to let a session finish once the screen has stopped changing: the
// retry backoffs, the quiet window and the minimum hold all fit inside it,
// while staying well under maxHoldMs. That gap matters — if a drain ran
// past the ceiling, "the veil lowered" would no longer distinguish
// "it settled" from "it gave up", which is the whole point of these tests.
private const val DRAIN_MS = 3_000L
/**
* The veil's job is to go up only when content actually changes, and then to
* stay up until the screen has stopped moving. Each test pins one of the ways
* the original fixed-delay implementation got that wrong (issue #2327).
*
* The controller is built on `backgroundScope` throughout: its consumer
* loop runs forever, so hanging it off the test's own scope would stop
* `runTest` from ever completing.
*
* Consequence, and the reason every wait below is an explicit
* `advanceTimeBy`: **`advanceUntilIdle()` is useless here.** It advances
* only while *foreground* work remains, and everything this controller
* does lives in `backgroundScope` — so it returns having run nothing, and
* assertions land on a session that never started (CI run 3163 failed all
* seven of these with "expected 3, actual 0" and friends). Drive the clock
* deliberately instead; don't "simplify" these back to advanceUntilIdle.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class UpdateVeilControllerTest {
private val timings = VeilTimings(
minHoldMs = 900,
quietMs = 700,
maxHoldMs = 12_000,
attempts = 3,
retryBackoffMs = 600,
)
/** Drives the settle signal by hand: content key, presence, art count. */
private class FakeScreen(hasContent: Boolean = true) {
val state = MutableStateFlow(Triple(0, hasContent, 0))
val signal = state.map { (key, hasContent, art) ->
VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0)
}
/** Content visibly changed — what the veil exists to cover. */
fun churn() {
state.value = state.value.copy(first = state.value.first + 1)
}
fun artLoading(count: Int) {
state.value = state.value.copy(third = count)
}
fun contentAppears() {
state.value = state.value.copy(second = true)
}
}
private fun TestScope.controllerOn(
screen: FakeScreen,
shouldVeil: suspend () -> Boolean = { true },
onSessionEnd: (VeilSessionResult) -> Unit = {},
work: suspend () -> Boolean,
) = UpdateVeilController(
scope = backgroundScope,
settleSignal = screen.signal,
shouldVeil = shouldVeil,
timings = timings,
onSessionEnd = onSessionEnd,
work = work,
)
/** Records every visibility transition, so an extra raise can't hide. */
private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> {
val seen = mutableListOf<Boolean>()
backgroundScope.launch { controller.visible.collect { seen.add(it) } }
return seen
}
@Test
fun `veil outlasts content that keeps churning after the pull returns`() = runTest {
val screen = FakeScreen()
val controller = controllerOn(screen) { true }
controller.request()
runCurrent()
// The pull's write lands: content changed, so the veil goes up.
screen.churn()
runCurrent()
assertTrue(controller.visible.value, "veil is up while the screen is still moving")
// Tiles hydrating one after another, each inside the quiet window.
// The old implementation had already wiped off after a flat 500ms.
repeat(CHURN_ROUNDS) {
advanceTimeBy(timings.quietMs / 2)
screen.churn()
runCurrent()
assertTrue(controller.visible.value, "veil must hold across staged churn")
}
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "veil lowers once the screen goes quiet")
}
@Test
fun `veil waits for artwork to finish loading`() = runTest {
val screen = FakeScreen()
val controller = controllerOn(screen) { true }
screen.artLoading(ART_IN_FLIGHT)
controller.request()
runCurrent()
screen.churn()
runCurrent()
// Well past the quiet window and the floor — but art is still in
// flight, so lowering now would show the covers popping in.
advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST)
assertTrue(controller.visible.value, "veil must wait on in-flight art")
screen.artLoading(0)
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "veil lowers once art has landed")
}
@Test
fun `retries quietly, then veils the churn the successful attempt produces`() = runTest {
val screen = FakeScreen()
var attempts = 0
val controller = controllerOn(screen) {
attempts++
val succeeded = attempts >= SUCCEED_ON_ATTEMPT // fail twice
// Only a pull that worked writes anything.
if (succeeded) screen.churn()
succeeded
}
val seen = recordVisibility(controller)
controller.request()
runCurrent()
// A failed pull changes nothing, so there is nothing to hide yet —
// the retries happen with no veil at all.
assertFalse(controller.visible.value, "no veil over a pull that changed nothing")
advanceTimeBy(DRAIN_MS)
assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
assertEquals(
listOf(false, true, false),
seen,
"the veil went up once, over the churn the retry finally produced",
)
}
@Test
fun `giving up is silent, reports FAILED, and does not block later requests`() = runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
var attempts = 0
var succeed = false
val controller = controllerOn(screen, onSessionEnd = { results += it }) {
attempts++
succeed
}
controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS)
assertEquals(timings.attempts, attempts, "exhausts its attempts")
assertFalse(controller.visible.value, "no veil — a failed pull changed nothing")
assertEquals(VeilOutcome.FAILED, results.single().outcome)
assertTrue(results.single().userInitiated, "the user asked, so they're owed an answer")
// Giving up must not latch anything off — the reconnect-driven
// recovery still gets to try again later.
succeed = true
controller.request()
advanceTimeBy(DRAIN_MS)
assertEquals(timings.attempts + 1, attempts, "a later request still runs")
}
@Test
fun `overlapping requests extend one veil instead of racing it`() = runTest {
val screen = FakeScreen()
var started = 0
val controller = controllerOn(screen) {
started++
delay(WORK_MS)
screen.churn()
true
}
val seen = recordVisibility(controller)
// Reconnect and the rebuild event arriving together is what made the
// old Boolean flag clear mid-update: whichever pull finished first
// wiped the veil off while the other was still running.
controller.request()
runCurrent()
controller.request()
advanceTimeBy(WORK_MS + 1)
assertTrue(controller.visible.value, "second trigger extends the same veil")
advanceTimeBy(DRAIN_MS)
assertEquals(2, started, "the mid-session trigger still did its pull")
assertEquals(listOf(false, true, false), seen, "one veil session, not two")
}
@Test
fun `a never-settling screen still releases the veil at the ceiling`() = runTest {
val screen = FakeScreen()
val controller = controllerOn(screen) {
screen.churn()
true
}
screen.artLoading(1) // an image that never completes
controller.request()
advanceTimeBy(timings.maxHoldMs + 1)
assertFalse(controller.visible.value, "the hard ceiling must never strand the user")
}
@Test
fun `an unchanged refresh never raises the veil and reports UNCHANGED`() = runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
// Succeeds without writing anything — the common case on a launch
// over a warm cache, where the server returns what's already cached.
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
val seen = recordVisibility(controller)
controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS)
assertEquals(listOf(false), seen, "a veil over an unchanged screen would hide nothing")
assertEquals(VeilOutcome.UNCHANGED, results.single().outcome)
assertTrue(results.single().userInitiated, "so the caller can say 'already up to date'")
}
@Test
fun `cached content painting is not mistaken for a change`() = runTest {
// Warm cache that hasn't painted yet: the rows arrive a moment after
// the session starts. Treating that first paint as churn would veil
// every single launch.
val screen = FakeScreen(hasContent = false)
val controller = controllerOn(screen) { true }
controller.request()
runCurrent()
screen.contentAppears()
advanceTimeBy(DRAIN_MS)
assertFalse(controller.visible.value, "first paint is not churn")
}
@Test
fun `a background trigger coalescing with the user's does not swallow their answer`() =
runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
// Conflation drops the older token, so the "a user asked" bit
// cannot ride on it — it's tracked separately for exactly this.
controller.request(userInitiated = true)
controller.request()
advanceTimeBy(DRAIN_MS)
assertTrue(
results.first().userInitiated,
"the user's request must not be conflated away",
)
}
@Test
fun `a cold load runs unveiled and is never reported as already up to date`() = runTest {
val screen = FakeScreen()
val results = mutableListOf<VeilSessionResult>()
var ran = false
val controller = controllerOn(
screen,
shouldVeil = { false }, // empty cache: the skeleton owns this
onSessionEnd = { results += it },
) {
ran = true
true
}
controller.request(userInitiated = true)
advanceTimeBy(DRAIN_MS)
assertTrue(ran, "the refresh still happens")
assertFalse(controller.visible.value, "but no veil over a skeleton")
// It went from nothing to something — that IS a change.
assertEquals(VeilOutcome.CHANGED, results.single().outcome)
}
}
+60
View File
@@ -44,4 +44,64 @@ None.
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
```yaml
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
```
Upstream's `@v4+` cannot work against this instance and no server-side change
will help: `isGhes()` rejects any hostname that isn't `github.com` /
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
server is never asked what it supports. `@v3` is worse — it reports success,
and Gitea then serves artifacts back only through the v4 API
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
to every retrieval path. A green job producing nothing retrievable; that is how
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
Both are pull mirrors of the Forgejo project's forks
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
disabling that check), mirrored so CI depends on commits we hold and pinned by
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
otherwise silently change what runs.
**Match the pins on `@actions/artifact`, not on the actions' own version
numbers.** The two actions release on unrelated cadences, so equal version
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
matters because `release.yml` is a producer/consumer pair — `android-release`
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
across it yields an empty listing rather than an error, exactly the silent
failure this entry exists to prevent.
| tag | `@actions/artifact` | runtime |
|---|---|---|
| upload v4 | ^2.1.1 | node20 |
| **upload v5** ← pinned | **^4.0.0** | node20 |
| download v4 | ^2.1.1 | node20 |
| download v5 | ^2.3.2 | node20 |
| **download v6** ← pinned | **^4.0.0** | node20 |
| download v7 | ^5.0.0 | **node24** |
The only true protocol break in this history was **v3 → v4** (upstream:
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
below are not supported"); v4-and-up are one family. Later majors are mostly
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
at 500 artifacts; download v5 made by-ID extraction match by-name.
**Do not jump the download pin to v7.** That major is a runner requirement, not
a feature change: it moves to `runs.using: node24` and upstream states it
"requires a minimum Actions Runner version of 2.327.1 … if you are using
self-hosted runners, ensure they are updated before upgrading." act_runner is
not GitHub's runner and makes no such version claim, so node24 is unverified
here. Everything currently pinned is node20.
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
an upload that matches nothing fails its own job instead of failing the
consumer later.
Retrieval: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
for the id (global run id, not the repo-scoped run number), then
`…/actions/artifacts/{id}/zip`. The workstation has no `unzip` — use
`python3 -m zipfile -e`.
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
+7
View File
@@ -15,6 +15,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
@@ -206,6 +207,12 @@ func run() error {
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
// Start the household co-play worker (#1533). Recomputes artistartist
// co-occurrence edges (source='user_cooccurrence') from play_events every
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
// SQL, no external calls; empty on single-user servers.
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
// tag providers with tag_provider_settings, bumps the sources version if
// the provider set changed (re-opening settled rows), then drains tracks
+24 -16
View File
@@ -20,26 +20,28 @@ import (
// weightsResp is one weight profile on the wire, keyed by the same
// snake_case field names the PATCH body accepts.
type weightsResp struct {
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
ContextTimeWeight float64 `json:"context_time_weight"`
}
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
return weightsResp{
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
ContextTimeWeight: w.ContextTimeWeight,
}
}
@@ -48,6 +50,9 @@ type tasteTuningResp struct {
EngagementHardSkip float64 `json:"engagement_hard_skip"`
EngagementNeutral float64 `json:"engagement_neutral"`
EngagementFull float64 `json:"engagement_full"`
EnrichedTagScale float64 `json:"enriched_tag_scale"`
EraScale float64 `json:"era_scale"`
MoodScale float64 `json:"mood_scale"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
@@ -56,6 +61,9 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}
}
+33 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -24,6 +25,33 @@ type eventRequest struct {
// / "discover" feed the system-playlist rotation dedup (#415);
// absent / "" for library, user-playlist, radio, Subsonic.
Source *string `json:"source"`
// DeviceClass is the client's self-reported device class (#1551),
// normalized + stored per play to condition radio on the current device.
DeviceClass *string `json:"device_class"`
}
// knownDeviceClasses whitelists the device classes the context-affinity facet
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
// "other", and empty/absent to "" (stored NULL → excluded from the device
// dimension). Kept permissive (no DB CHECK) so a new client class is one
// whitelist entry, not a migration.
var knownDeviceClasses = map[string]bool{
"mobile": true, "tablet": true, "desktop": true,
"web": true, "tv": true, "watch": true,
}
func normalizeDeviceClass(dc *string) string {
if dc == nil {
return ""
}
s := strings.ToLower(strings.TrimSpace(*dc))
if s == "" {
return ""
}
if knownDeviceClasses[s] {
return s
}
return "other"
}
type playStartedResponse struct {
@@ -114,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
if req.Source != nil {
source = *req.Source
}
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
deviceClass := normalizeDeviceClass(req.DeviceClass)
res, err := h.events.RecordPlayStartedWithSource(
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
if err != nil {
h.logger.Error("api: events: play_started", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
@@ -159,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
source = *req.Source
}
if err := h.events.RecordOfflinePlay(
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
r.Context(), user.ID, trackID, clientID, source,
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
); err != nil {
h.logger.Error("api: events: play_offline", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
+22
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
@@ -81,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
}
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
// Condition on the current device (#1551): the latest play's device is a
// cheap, request-free proxy for what the user is on right now.
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
limits := recommendation.DefaultCandidateSourceLimits()
@@ -150,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
return v
}
// latestDeviceClass returns the device_class of the user's most recent play as
// the "current device" for context conditioning (#1551), or "" when unknown
// (no plays yet, or the latest play predates device capture). Best-effort: a
// lookup failure yields a device-agnostic ("") affinity cell.
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
logger.Warn("api: radio: latest device class", "err", err)
}
return ""
}
if dc == nil {
return ""
}
return *dc
}
// parseExcludeParam parses a comma-separated list of UUIDs from the
// `exclude` query string, silently dropping malformed entries. Returns
// nil for empty or all-malformed input.
+86
View File
@@ -0,0 +1,86 @@
// Package coplay builds household co-play similarity edges (#1533, milestone
// #160 Opt 5). Minstrel is a single shared-library, multi-user server (no
// per-user library ACL), so the "household" is the whole instance's user set.
// A periodic worker recomputes artistartist co-occurrence from play_events
// entirely in SQL (no external API) and stores the edges in artist_similarity
// under source='user_cooccurrence' — the pre-provisioned co-occurrence source
// from the 0009 schema — which the radio/mix candidate pool consumes as a
// collaborative arm. Single-user servers produce no edges (the >= 2 co-player
// gate), so the feature is a graceful no-op there.
package coplay
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// minJaccard is the co-play edge floor: prune weak pairs and self-limit hub
// artists, whose large combined-audience denominators pull their Jaccard down.
// 0.1 keeps only pairs whose co-player overlap is a real fraction of their
// combined audience.
const minJaccard = 0.1
// Worker periodically rebuilds the household co-play edge set. Pure local SQL,
// so there are no rate limits — but the pair self-join is roughly
// O(users × artists_per_user²), so it ticks slowly: co-listening shifts over
// days, not minutes, and a frequent rebuild would burn CPU for no fresher
// signal.
type Worker struct {
pool *pgxpool.Pool
logger *slog.Logger
tick time.Duration
}
// NewWorker constructs a worker with the production default 6h tick.
func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker {
return &Worker{pool: pool, logger: logger, tick: 6 * time.Hour}
}
// Run rebuilds once at startup (so edges exist without waiting a full tick),
// then every w.tick until ctx is cancelled. Runs in its own goroutine, so a
// slow initial rebuild never blocks boot.
func (w *Worker) Run(ctx context.Context) {
if err := w.rebuild(ctx); err != nil {
w.logger.Error("coplay: initial rebuild failed", "err", err)
}
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := w.rebuild(ctx); err != nil {
w.logger.Error("coplay: rebuild failed", "err", err)
}
}
}
}
// rebuild atomic-replaces the user_cooccurrence edge set inside one
// transaction — delete the old edges, recompute from current play history — so
// the candidate pool never observes a half-built set.
func (w *Worker) rebuild(ctx context.Context) error {
tx, err := w.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
q := dbq.New(tx)
if err := q.DeleteArtistCoplayEdges(ctx); err != nil {
return err
}
if err := q.InsertArtistCoplayEdges(ctx, minJaccard); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
w.logger.Debug("coplay: edges rebuilt")
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package coplay
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID {
t.Helper()
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user %s: %v", name, err)
}
return u.ID
}
// seedArtistTrack creates an artist + album + one track, returning both ids.
func seedArtistTrack(t *testing.T, q *dbq.Queries, name string) (pgtype.UUID, pgtype.UUID) {
t.Helper()
ctx := context.Background()
a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name})
if err != nil {
t.Fatalf("seed artist: %v", err)
}
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: a.ID})
if err != nil {
t.Fatalf("seed album: %v", err)
}
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: al.ID, ArtistID: a.ID,
DurationMs: 1000, FilePath: "/tmp/coplay-" + name + ".mp3",
FileSize: 1, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("seed track: %v", err)
}
return a.ID, tr.ID
}
func play(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
t.Helper()
ctx := context.Background()
at := time.Now()
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'coplay-test') RETURNING id`,
userID, at).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
VALUES ($1, $2, $3, $4, false)`,
userID, trackID, sessionID, at); err != nil {
t.Fatalf("insert play_event: %v", err)
}
}
func coplayEdgeCount(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
var n int
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM artist_similarity WHERE source='user_cooccurrence'`).Scan(&n); err != nil {
t.Fatalf("count edges: %v", err)
}
return n
}
// TestCoplayWorker_BuildsEdges: two users who both play two artists produce a
// perfect-overlap co-play edge (Jaccard 1.0) in each direction.
func TestCoplayWorker_BuildsEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
u1 := seedUser(t, q, "cop1")
u2 := seedUser(t, q, "cop2")
aA, tA := seedArtistTrack(t, q, "ArtA")
aB, tB := seedArtistTrack(t, q, "ArtB")
for _, u := range []pgtype.UUID{u1, u2} {
play(t, pool, u, tA)
play(t, pool, u, tB)
}
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
t.Fatalf("rebuild: %v", err)
}
var score float64
if err := pool.QueryRow(ctx,
`SELECT score FROM artist_similarity
WHERE artist_a_id=$1 AND artist_b_id=$2 AND source='user_cooccurrence'`,
aA, aB).Scan(&score); err != nil {
t.Fatalf("expected co-play edge ArtA→ArtB: %v", err)
}
// Jaccard of perfectly-overlapping player sets (both users play both):
// coplayers=2, players each=2 → 2/(2+2-2) = 1.0.
if score != 1.0 {
t.Errorf("edge score = %v, want 1.0 (perfect overlap)", score)
}
if got := coplayEdgeCount(t, pool); got != 2 {
t.Errorf("edge count = %d, want 2 (A→B and B→A)", got)
}
}
// TestCoplayWorker_SingleUserNoEdges: one user's own co-listening yields no
// edges — the >= 2 distinct-co-player gate keeps single-user servers empty.
func TestCoplayWorker_SingleUserNoEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
u := seedUser(t, q, "solo")
_, tA := seedArtistTrack(t, q, "SoloA")
_, tB := seedArtistTrack(t, q, "SoloB")
play(t, pool, u, tA)
play(t, pool, u, tB)
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
t.Fatalf("rebuild: %v", err)
}
if got := coplayEdgeCount(t, pool); got != 0 {
t.Errorf("single-user edge count = %d, want 0 (>= 2 co-player gate)", got)
}
}
+78
View File
@@ -0,0 +1,78 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: coplay.sql
package dbq
import (
"context"
)
const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence'
`
// Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
// single shared-library, multi-user server (no per-user library ACL), so the
// "household" is the whole instance's user set. These queries recompute
// artistartist co-occurrence edges from play_events and store them in
// artist_similarity under the pre-provisioned source = 'user_cooccurrence'
// (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then
// re-INSERTs so edges that fall below threshold disappear. Single-user servers
// produce no edges (the >= 2 co-player gate), so the arm is empty there.
func (q *Queries) DeleteArtistCoplayEdges(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteArtistCoplayEdges)
return err
}
const insertArtistCoplayEdges = `-- name: InsertArtistCoplayEdges :exec
WITH user_artists AS (
SELECT DISTINCT pe.user_id, t.artist_id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
),
artist_players AS (
SELECT artist_id, count(*)::float8 AS players
FROM user_artists
GROUP BY artist_id
),
pairs AS (
SELECT ua.artist_id AS a_id,
ub.artist_id AS b_id,
count(*)::float8 AS coplayers
FROM user_artists ua
JOIN user_artists ub
ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id
GROUP BY ua.artist_id, ub.artist_id
HAVING count(*) >= 2
),
scored AS (
SELECT p.a_id, p.b_id,
p.coplayers / (pa.players + pb.players - p.coplayers) AS score
FROM pairs p
JOIN artist_players pa ON pa.artist_id = p.a_id
JOIN artist_players pb ON pb.artist_id = p.b_id
)
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
SELECT a_id, b_id, score, 'user_cooccurrence', now()
FROM scored
WHERE score >= $1
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at
`
// Two artists are "co-played" when the same users play both. score is the
// Jaccard of their distinct-player sets — coplayers / (playersA + playersB
// coplayers) in (0,1] — which controls for globally-popular artists (an artist
// everyone plays would otherwise co-occur with everything). Only pairs with
// >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak
// edges and self-limits hub artists, whose large denominators drag their
// Jaccard down) are kept. Completed plays only, 365-day window.
func (q *Queries) InsertArtistCoplayEdges(ctx context.Context, score float64) error {
_, err := q.db.Exec(ctx, insertArtistCoplayEdges, score)
return err
}
+21 -13
View File
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
}
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events
WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1
@@ -80,12 +80,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
&i.DeviceClass,
)
return i, err
}
const getPlayEventByID = `-- name: GetPlayEventByID :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events WHERE id = $1
`
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
@@ -106,6 +107,7 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
&i.DeviceClass,
)
return i, err
}
@@ -144,19 +146,22 @@ func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPi
const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
device_class
) VALUES ($1, $2, $3, $4, $5, $6, $7::text,
$8::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
`
type InsertPlayEventParams struct {
UserID pgtype.UUID
TrackID pgtype.UUID
SessionID pgtype.UUID
StartedAt pgtype.Timestamptz
ClientID *string
Source *string
PickKind *string
UserID pgtype.UUID
TrackID pgtype.UUID
SessionID pgtype.UUID
StartedAt pgtype.Timestamptz
ClientID *string
Source *string
PickKind *string
DeviceClass *string
}
// pick_kind is non-NULL only for system-playlist plays whose track was
@@ -171,6 +176,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
arg.ClientID,
arg.Source,
arg.PickKind,
arg.DeviceClass,
)
var i PlayEvent
err := row.Scan(
@@ -188,6 +194,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
&i.DeviceClass,
)
return i, err
}
@@ -333,7 +340,7 @@ SET ended_at = $2,
completion_ratio = $4,
was_skipped = $5
WHERE id = $1
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
`
type UpdatePlayEventEndedParams struct {
@@ -371,6 +378,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
&i.DeviceClass,
)
return i, err
}
+29 -10
View File
@@ -384,6 +384,7 @@ type PlayEvent struct {
ScrobbledAt pgtype.Timestamptz
Source *string
PickKind *string
DeviceClass *string
}
type PlaySession struct {
@@ -446,16 +447,17 @@ type RecommendationTuningAudit struct {
}
type RecommendationWeightProfile struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
UpdatedAt pgtype.Timestamptz
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
UpdatedAt pgtype.Timestamptz
ContextTimeWeight float64
}
type RegistrationSetting struct {
@@ -554,6 +556,20 @@ type TasteProfileArtist struct {
UpdatedAt pgtype.Timestamptz
}
type TasteProfileEra struct {
UserID pgtype.UUID
Era string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileMood struct {
UserID pgtype.UUID
Mood string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileTag struct {
UserID pgtype.UUID
Tag string
@@ -568,6 +584,9 @@ type TasteTuning struct {
EngagementNeutral float64
EngagementFull float64
UpdatedAt pgtype.Timestamptz
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
type Track struct {
+138 -3
View File
@@ -11,6 +11,110 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
SELECT device_class
FROM play_events
WHERE user_id = $1
ORDER BY started_at DESC
LIMIT 1
`
// The device_class of the user's most recent play (#1551), used as the
// "current device" for radio context conditioning. NULL when the latest play
// predates device capture or came from a client that didn't send one.
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
var device_class *string
err := row.Scan(&device_class)
return device_class, err
}
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
WITH tz AS (
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
FROM users u WHERE u.id = $1
),
now_cell AS (
SELECT
CASE
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM tz
),
plays AS (
SELECT t.artist_id,
pe.device_class,
CASE
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
CROSS JOIN tz
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
)
SELECT p.artist_id,
count(*) AS total_plays,
count(*) FILTER (
WHERE p.daypart = (SELECT daypart FROM now_cell)
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
AND ($2::text = '' OR p.device_class = $2::text)
) AS cell_plays
FROM plays p
GROUP BY p.artist_id
`
type ListArtistContextPlayCountsForUserParams struct {
ID pgtype.UUID
Column2 string
}
type ListArtistContextPlayCountsForUserRow struct {
ArtistID pgtype.UUID
TotalPlays int64
CellPlays int64
}
// Per-artist completed-play counts split by whether each play falls in the
// CURRENT context cell, in the user's local timezone (#1531). The cell is
// daypart × weekday-type, optionally narrowed by device class (#1551): when
// $2 (the current device) is ”, the device dimension is ignored (identical to
// the time-only #1531 behaviour, used by the daily mixes which have no device);
// when $2 is set (radio), a play only counts in the cell if its device_class
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
// afternoon [12,17) evening [17,22). Weekend = ISO days 67 (Sat/Sun).
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListArtistContextPlayCountsForUserRow
for rows.Next() {
var i ListArtistContextPlayCountsForUserRow
if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
WITH user_plays AS (
SELECT t.artist_id, max(pe.started_at) AS last_started
@@ -585,8 +689,10 @@ SELECT
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count
pe.skip_count,
al.release_date AS release_date
FROM tracks t
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT
@@ -620,6 +726,7 @@ type LoadRadioCandidatesRow struct {
LastPlayedAt pgtype.Timestamptz
PlayCount int64
SkipCount int64
ReleaseDate pgtype.Date
}
// Returns all tracks except the seed and any played by the user within
@@ -660,6 +767,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.LastPlayedAt,
&i.PlayCount,
&i.SkipCount,
&i.ReleaseDate,
); err != nil {
return nil, err
}
@@ -754,6 +862,22 @@ taste_overlap AS (
ORDER BY tpa.weight DESC, t.id
LIMIT $10
),
coplay_artists AS (
-- Household co-play (#1533): tracks by artists co-played across the
-- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
-- similar_artists since it's artist-level.
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'user_cooccurrence'
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY asim.score DESC, random()
LIMIT $11
),
random_fill AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM tracks t
@@ -765,6 +889,7 @@ random_fill AS (
UNION SELECT track_id FROM tag_overlap
UNION SELECT track_id FROM likes_overlap
UNION SELECT track_id FROM taste_overlap
UNION SELECT track_id FROM coplay_artists
)
ORDER BY random()
LIMIT $9
@@ -775,6 +900,7 @@ SELECT
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count,
al.release_date AS release_date,
COALESCE(max(u.sim_score), 0.0) AS similarity_score
FROM (
SELECT track_id, sim_score FROM lb_similar
@@ -782,9 +908,11 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM tag_overlap
UNION ALL SELECT track_id, sim_score FROM likes_overlap
UNION ALL SELECT track_id, sim_score FROM taste_overlap
UNION ALL SELECT track_id, sim_score FROM coplay_artists
UNION ALL SELECT track_id, sim_score FROM random_fill
) u
JOIN tracks t ON t.id = u.track_id
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at,
@@ -796,7 +924,8 @@ LEFT JOIN LATERAL (
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
al.release_date
`
type LoadRadioCandidatesV2Params struct {
@@ -810,6 +939,7 @@ type LoadRadioCandidatesV2Params struct {
Limit_4 int32
Limit_5 int32
Limit_6 int32
Limit_7 int32
}
type LoadRadioCandidatesV2Row struct {
@@ -818,6 +948,7 @@ type LoadRadioCandidatesV2Row struct {
LastPlayedAt pgtype.Timestamptz
PlayCount int64
SkipCount int64
ReleaseDate pgtype.Date
SimilarityScore interface{}
}
@@ -829,7 +960,9 @@ type LoadRadioCandidatesV2Row struct {
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
// positively-weighted taste-profile artists, so taste-relevant tracks
// enter the pool even when the similarity/random arms miss them; scored
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
// instance with the seed's artist; source='user_cooccurrence').
//
// Returns same shape as LoadRadioCandidates plus similarity_score column.
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
@@ -844,6 +977,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
arg.Limit_4,
arg.Limit_5,
arg.Limit_6,
arg.Limit_7,
)
if err != nil {
return nil, err
@@ -874,6 +1008,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.LastPlayedAt,
&i.PlayCount,
&i.SkipCount,
&i.ReleaseDate,
&i.SimilarityScore,
); err != nil {
return nil, err
+65 -35
View File
@@ -10,7 +10,7 @@ import (
)
const getTasteTuning = `-- name: GetTasteTuning :one
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
`
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
@@ -23,6 +23,9 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
return i, err
}
@@ -80,7 +83,7 @@ func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]Recommend
}
const listWeightProfiles = `-- name: ListWeightProfiles :many
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight FROM recommendation_weight_profiles ORDER BY profile
`
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
@@ -103,6 +106,7 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
&i.ContextTimeWeight,
); err != nil {
return nil, err
}
@@ -120,9 +124,12 @@ UPDATE taste_tuning
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
enriched_tag_scale = $5,
era_scale = $6,
mood_scale = $7,
updated_at = now()
WHERE singleton = true
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale
`
type UpdateTasteTuningParams struct {
@@ -130,6 +137,9 @@ type UpdateTasteTuningParams struct {
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
@@ -138,6 +148,9 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
var i TasteTuning
err := row.Scan(
@@ -147,35 +160,40 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
return i, err
}
const updateWeightProfile = `-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
context_time_weight = $10,
updated_at = now()
WHERE profile = $1
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight
`
type UpdateWeightProfileParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
@@ -189,6 +207,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
arg.ContextTimeWeight,
)
var i RecommendationWeightProfile
err := row.Scan(
@@ -202,6 +221,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
&i.ContextTimeWeight,
)
return i, err
}
@@ -209,8 +229,9 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
mood_scale
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (singleton) DO NOTHING
`
@@ -219,6 +240,9 @@ type UpsertTasteTuningDefaultsParams struct {
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
@@ -227,6 +251,9 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
return err
}
@@ -235,21 +262,23 @@ const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
jitter_magnitude, context_weight, similarity_weight, taste_weight,
context_time_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (profile) DO NOTHING
`
type UpsertWeightProfileDefaultsParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
// Recommendation tuning lab queries (#1250). Seeding happens via the
@@ -267,6 +296,7 @@ func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWei
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
arg.ContextTimeWeight,
)
return err
}
+154 -14
View File
@@ -20,6 +20,24 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
return err
}
const deleteTasteProfileErasForUser = `-- name: DeleteTasteProfileErasForUser :exec
DELETE FROM taste_profile_eras WHERE user_id = $1
`
func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTasteProfileErasForUser, userID)
return err
}
const deleteTasteProfileMoodsForUser = `-- name: DeleteTasteProfileMoodsForUser :exec
DELETE FROM taste_profile_moods WHERE user_id = $1
`
func (q *Queries) DeleteTasteProfileMoodsForUser(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTasteProfileMoodsForUser, userID)
return err
}
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
DELETE FROM taste_profile_tags WHERE user_id = $1
`
@@ -45,6 +63,38 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
return err
}
const insertTasteProfileEra = `-- name: InsertTasteProfileEra :exec
INSERT INTO taste_profile_eras (user_id, era, weight)
VALUES ($1, $2, $3)
`
type InsertTasteProfileEraParams struct {
UserID pgtype.UUID
Era string
Weight float64
}
func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProfileEraParams) error {
_, err := q.db.Exec(ctx, insertTasteProfileEra, arg.UserID, arg.Era, arg.Weight)
return err
}
const insertTasteProfileMood = `-- name: InsertTasteProfileMood :exec
INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3)
`
type InsertTasteProfileMoodParams struct {
UserID pgtype.UUID
Mood string
Weight float64
}
func (q *Queries) InsertTasteProfileMood(ctx context.Context, arg InsertTasteProfileMoodParams) error {
_, err := q.db.Exec(ctx, insertTasteProfileMood, arg.UserID, arg.Mood, arg.Weight)
return err
}
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
INSERT INTO taste_profile_tags (user_id, tag, weight)
VALUES ($1, $2, $3)
@@ -87,21 +137,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
}
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
SELECT t.id AS track_id, t.artist_id, t.genre
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
JOIN albums a ON a.id = t.album_id
WHERE gl.user_id = $1
`
type ListLikedTrackTasteInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
ReleaseDate pgtype.Date
}
// (track_id, artist_id, genre) for each track the user has explicitly
// liked. Feeds the track-like bonus into the liked track's artist and
// tags; track_id keys the enriched track_tags lookup (#1490).
// (track_id, artist_id, genre, release_date) for each track the user has
// explicitly liked. Feeds the track-like bonus into the liked track's artist,
// tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
if err != nil {
@@ -111,7 +163,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
var items []ListLikedTrackTasteInputsForUserRow
for rows.Next() {
var i ListLikedTrackTasteInputsForUserRow
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
if err := rows.Scan(
&i.TrackID,
&i.ArtistID,
&i.Genre,
&i.ReleaseDate,
); err != nil {
return nil, err
}
items = append(items, i)
@@ -128,6 +185,7 @@ SELECT
t.id AS track_id,
t.artist_id,
t.genre,
a.release_date,
LEAST(GREATEST(
COALESCE(pe.completion_ratio,
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
@@ -135,6 +193,7 @@ SELECT
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS (
@@ -149,11 +208,12 @@ type ListPlayEngagementInputsForUserParams struct {
}
type ListPlayEngagementInputsForUserRow struct {
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
Completion float64
AgeDays float64
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
ReleaseDate pgtype.Date
Completion float64
AgeDays float64
}
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
@@ -162,7 +222,8 @@ type ListPlayEngagementInputsForUserRow struct {
// One row per play in the decay-relevant window. completion is the play's
// completion ratio (precomputed column when present, else duration_played /
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
// is split into tags in Go. Quarantined tracks are excluded.
// is split into tags in Go; release_date derives the decade for the era
// facet (#1530). Quarantined tracks are excluded.
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
if err != nil {
@@ -176,6 +237,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
&i.TrackID,
&i.ArtistID,
&i.Genre,
&i.ReleaseDate,
&i.Completion,
&i.AgeDays,
); err != nil {
@@ -228,6 +290,84 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
return items, nil
}
const listTasteProfileErasForUser = `-- name: ListTasteProfileErasForUser :many
SELECT era, weight
FROM taste_profile_eras
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2
`
type ListTasteProfileErasForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListTasteProfileErasForUserRow struct {
Era string
Weight float64
}
// Top-weighted taste eras (#1530); consumed by the scorer's era term.
func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteProfileErasForUserRow
for rows.Next() {
var i ListTasteProfileErasForUserRow
if err := rows.Scan(&i.Era, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasteProfileMoodsForUser = `-- name: ListTasteProfileMoodsForUser :many
SELECT mood, weight
FROM taste_profile_moods
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2
`
type ListTasteProfileMoodsForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListTasteProfileMoodsForUserRow struct {
Mood string
Weight float64
}
// Top-weighted taste moods (#1534); consumed by the scorer's mood term.
func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteProfileMoodsForUserRow
for rows.Next() {
var i ListTasteProfileMoodsForUserRow
if err := rows.Scan(&i.Mood, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
SELECT tag, weight
FROM taste_profile_tags
+39 -2
View File
@@ -110,9 +110,43 @@ func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayed
return items, nil
}
const listTrackTagsForTracks = `-- name: ListTrackTagsForTracks :many
SELECT tt.track_id, tt.tag
FROM track_tags tt
WHERE tt.track_id = ANY($1::uuid[])
`
type ListTrackTagsForTracksRow struct {
TrackID pgtype.UUID
Tag string
}
// Enriched tags for a set of candidate tracks (#1534), so the scorer can
// derive each candidate's mood buckets at scoring time. One row per (track,
// tag); the caller filters to mood words via the internal/mood vocabulary.
func (q *Queries) ListTrackTagsForTracks(ctx context.Context, dollar_1 []pgtype.UUID) ([]ListTrackTagsForTracksRow, error) {
rows, err := q.db.Query(ctx, listTrackTagsForTracks, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTrackTagsForTracksRow
for rows.Next() {
var i ListTrackTagsForTracksRow
if err := rows.Scan(&i.TrackID, &i.Tag); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
SELECT t.id, t.mbid, t.title, a.name AS artist_name
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
FROM tracks t
JOIN artists a ON a.id = t.artist_id
WHERE t.tag_source IS NULL
@@ -131,6 +165,7 @@ type ListTracksMissingTagsRow struct {
Mbid *string
Title string
ArtistName string
ArtistMbid *string
}
// Track-tag enrichment queries (milestone #160, #1490). The tag enricher
@@ -140,7 +175,8 @@ type ListTracksMissingTagsRow struct {
// Tracks eligible for tag enrichment: never processed (tag_source NULL)
// or previously settled 'none' under an older provider version. Returns
// the fields the provider chain needs — recording MBID (nullable) for
// keyed lookups, plus title + artist name for name-based fallback.
// keyed lookups, title + artist name for name-based fallback, and the
// artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
// $1 = current tag_sources_version, $2 = limit.
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit)
@@ -156,6 +192,7 @@ func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissi
&i.Mbid,
&i.Title,
&i.ArtistName,
&i.ArtistMbid,
); err != nil {
return nil, err
}
@@ -0,0 +1,2 @@
-- Reverse 0044_taste_tuning_enriched_tag_scale.up.sql.
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS enriched_tag_scale;
@@ -0,0 +1,6 @@
-- Expose EnrichedTagScale in the tuning lab (#1520). Adds the enriched-tag
-- weight knob to the taste_tuning singleton so operators can dial how much
-- folksonomy tags (#1490) count vs raw ID3 genre. DEFAULT 0.5 backfills the
-- existing row and matches taste.DefaultConfig().EnrichedTagScale.
ALTER TABLE taste_tuning
ADD COLUMN enriched_tag_scale double precision NOT NULL DEFAULT 0.5;
@@ -0,0 +1,2 @@
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS era_scale;
DROP TABLE IF EXISTS taste_profile_eras;
@@ -0,0 +1,26 @@
-- 0045_taste_profile_eras.up.sql — era/decade taste facet (#1530,
-- milestone #160 Opt 2). A third taste facet alongside artists + tags:
-- signed weights over decade buckets ("1990s") derived from
-- albums.release_date, rebuilt daily by internal/taste next to the
-- artist/tag facets. Weight is a signed float — positive = drawn to
-- that era, negative = passively avoided; magnitude reflects decayed
-- engagement. Consumed by the recommendation scorer's taste match.
-- Mirrors taste_profile_tags: CASCADE on user delete, indexed by
-- (user_id, weight DESC) so "top eras" reads stay cheap.
CREATE TABLE taste_profile_eras (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
era text NOT NULL,
weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, era)
);
CREATE INDEX taste_profile_eras_user_weight_idx
ON taste_profile_eras (user_id, weight DESC);
-- Era-facet build knob, tunable in the lab (mirrors enriched_tag_scale,
-- #1520): scales how strongly a decade-play imprints on the profile.
-- DEFAULT 0.5 backfills the existing singleton and matches
-- taste.DefaultConfig().EraScale; 0 disables the era facet entirely.
ALTER TABLE taste_tuning
ADD COLUMN era_scale double precision NOT NULL DEFAULT 0.5;
@@ -0,0 +1 @@
ALTER TABLE recommendation_weight_profiles DROP COLUMN IF EXISTS context_time_weight;
@@ -0,0 +1,12 @@
-- 0046_context_time_weight.up.sql — time-of-day/weekday context conditioning
-- (#1531, milestone #160 Opt 3). Adds a per-profile scoring weight for the new
-- context-affinity term: how strongly a candidate is boosted when its artist's
-- play history concentrates in the current daypart × weekday-type cell (in the
-- user's local timezone). Mirrors the other ScoringWeights columns.
--
-- DEFAULT 1.0 backfills both existing profile rows to a modest on-value; the
-- Go ShippedRadioWeights/ShippedDailyMixWeights carry the same 1.0 so fresh
-- installs seed identically. Reconcile only seeds MISSING rows (ON CONFLICT DO
-- NOTHING), so existing rows rely on this DEFAULT until an operator resets.
ALTER TABLE recommendation_weight_profiles
ADD COLUMN context_time_weight double precision NOT NULL DEFAULT 1.0;
@@ -0,0 +1,2 @@
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS mood_scale;
DROP TABLE IF EXISTS taste_profile_moods;
@@ -0,0 +1,25 @@
-- 0047_taste_profile_moods.up.sql — mood taste facet (#1534, milestone #160
-- Opt 2b). A fourth taste facet alongside artists + tags + eras: signed weights
-- over canonical mood buckets (melancholic / energetic / chill / …) derived
-- from a track's enriched folksonomy tags (#1490) via the internal/mood
-- vocabulary, rebuilt daily. Weight is a signed float — positive = drawn to
-- that mood, negative = passively avoided. Coverage is partial (grows with tag
-- enrichment), so it's a supplement, not a foundation. Mirrors
-- taste_profile_tags: CASCADE on user delete, indexed by (user_id, weight DESC).
CREATE TABLE taste_profile_moods (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mood text NOT NULL,
weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, mood)
);
CREATE INDEX taste_profile_moods_user_weight_idx
ON taste_profile_moods (user_id, weight DESC);
-- Mood-facet build knob, tunable in the lab (mirrors era_scale / enriched_tag_
-- scale): scales how strongly a mood-tagged play imprints on the profile.
-- DEFAULT 0.5 backfills the existing singleton and matches
-- taste.DefaultConfig().MoodScale; 0 disables the mood facet entirely.
ALTER TABLE taste_tuning
ADD COLUMN mood_scale double precision NOT NULL DEFAULT 0.5;
@@ -0,0 +1 @@
ALTER TABLE play_events DROP COLUMN IF EXISTS device_class;
@@ -0,0 +1,9 @@
-- 0048_play_events_device_class.up.sql — device-class context conditioning
-- (#1551, milestone #160 Opt 3b). Records which class of device a play came
-- from (mobile / web / …), sent by the client on play-start and normalized
-- server-side. Extends the #1531 time-of-day/weekday affinity cell to
-- (daypart × weekday × device) for the radio path. Nullable + no CHECK: values
-- are normalized in Go (avoids a CHECK-migration each time a client class is
-- added, rule #36), and historical rows stay NULL → excluded from the device
-- dimension (a graceful cold-start; the facet ramps as new plays land).
ALTER TABLE play_events ADD COLUMN device_class text;
+55
View File
@@ -0,0 +1,55 @@
-- Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
-- single shared-library, multi-user server (no per-user library ACL), so the
-- "household" is the whole instance's user set. These queries recompute
-- artistartist co-occurrence edges from play_events and store them in
-- artist_similarity under the pre-provisioned source = 'user_cooccurrence'
-- (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then
-- re-INSERTs so edges that fall below threshold disappear. Single-user servers
-- produce no edges (the >= 2 co-player gate), so the arm is empty there.
-- name: DeleteArtistCoplayEdges :exec
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence';
-- name: InsertArtistCoplayEdges :exec
-- Two artists are "co-played" when the same users play both. score is the
-- Jaccard of their distinct-player sets — coplayers / (playersA + playersB
-- coplayers) in (0,1] — which controls for globally-popular artists (an artist
-- everyone plays would otherwise co-occur with everything). Only pairs with
-- >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak
-- edges and self-limits hub artists, whose large denominators drag their
-- Jaccard down) are kept. Completed plays only, 365-day window.
WITH user_artists AS (
SELECT DISTINCT pe.user_id, t.artist_id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
),
artist_players AS (
SELECT artist_id, count(*)::float8 AS players
FROM user_artists
GROUP BY artist_id
),
pairs AS (
SELECT ua.artist_id AS a_id,
ub.artist_id AS b_id,
count(*)::float8 AS coplayers
FROM user_artists ua
JOIN user_artists ub
ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id
GROUP BY ua.artist_id, ub.artist_id
HAVING count(*) >= 2
),
scored AS (
SELECT p.a_id, p.b_id,
p.coplayers / (pa.players + pb.players - p.coplayers) AS score
FROM pairs p
JOIN artist_players pa ON pa.artist_id = p.a_id
JOIN artist_players pb ON pb.artist_id = p.b_id
)
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
SELECT a_id, b_id, score, 'user_cooccurrence', now()
FROM scored
WHERE score >= $1
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
+4 -2
View File
@@ -28,8 +28,10 @@ LIMIT 1;
-- found (with a stamped kind) in the user's live snapshot for that
-- variant at ingestion time (#1249, generalized in #1270).
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
device_class
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text,
sqlc.narg(device_class)::text)
RETURNING *;
-- name: GetSystemPickKindForTrack :one
+92 -3
View File
@@ -10,8 +10,10 @@ SELECT
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count
pe.skip_count,
al.release_date AS release_date
FROM tracks t
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT
@@ -40,7 +42,9 @@ WHERE t.id <> $2
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
-- positively-weighted taste-profile artists, so taste-relevant tracks
-- enter the pool even when the similarity/random arms miss them; scored
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
-- $11 coplay_artists K (#1533 — tracks by artists co-played across the
-- instance with the seed's artist; source='user_cooccurrence').
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
WITH
@@ -124,6 +128,22 @@ taste_overlap AS (
ORDER BY tpa.weight DESC, t.id
LIMIT $10
),
coplay_artists AS (
-- Household co-play (#1533): tracks by artists co-played across the
-- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
-- similar_artists since it's artist-level.
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'user_cooccurrence'
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY asim.score DESC, random()
LIMIT $11
),
random_fill AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM tracks t
@@ -135,6 +155,7 @@ random_fill AS (
UNION SELECT track_id FROM tag_overlap
UNION SELECT track_id FROM likes_overlap
UNION SELECT track_id FROM taste_overlap
UNION SELECT track_id FROM coplay_artists
)
ORDER BY random()
LIMIT $9
@@ -145,6 +166,7 @@ SELECT
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count,
al.release_date AS release_date,
COALESCE(max(u.sim_score), 0.0) AS similarity_score
FROM (
SELECT track_id, sim_score FROM lb_similar
@@ -152,9 +174,11 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM tag_overlap
UNION ALL SELECT track_id, sim_score FROM likes_overlap
UNION ALL SELECT track_id, sim_score FROM taste_overlap
UNION ALL SELECT track_id, sim_score FROM coplay_artists
UNION ALL SELECT track_id, sim_score FROM random_fill
) u
JOIN tracks t ON t.id = u.track_id
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at,
@@ -166,7 +190,72 @@ LEFT JOIN LATERAL (
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
al.release_date;
-- name: ListArtistContextPlayCountsForUser :many
-- Per-artist completed-play counts split by whether each play falls in the
-- CURRENT context cell, in the user's local timezone (#1531). The cell is
-- daypart × weekday-type, optionally narrowed by device class (#1551): when
-- $2 (the current device) is '', the device dimension is ignored (identical to
-- the time-only #1531 behaviour, used by the daily mixes which have no device);
-- when $2 is set (radio), a play only counts in the cell if its device_class
-- matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
-- window bounds cost. Daypart buckets: night [22,5) morning [5,12)
-- afternoon [12,17) evening [17,22). Weekend = ISO days 67 (Sat/Sun).
WITH tz AS (
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
FROM users u WHERE u.id = $1
),
now_cell AS (
SELECT
CASE
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM tz
),
plays AS (
SELECT t.artist_id,
pe.device_class,
CASE
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
CROSS JOIN tz
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
)
SELECT p.artist_id,
count(*) AS total_plays,
count(*) FILTER (
WHERE p.daypart = (SELECT daypart FROM now_cell)
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
AND ($2::text = '' OR p.device_class = $2::text)
) AS cell_plays
FROM plays p
GROUP BY p.artist_id;
-- name: GetLatestPlayDeviceClassForUser :one
-- The device_class of the user's most recent play (#1551), used as the
-- "current device" for radio context conditioning. NULL when the latest play
-- predates device capture or came from a client that didn't send one.
SELECT device_class
FROM play_events
WHERE user_id = $1
ORDER BY started_at DESC
LIMIT 1;
-- name: SuggestArtistsForUser :many
-- M5c: per-user artist suggestions ranked by signal x similarity. The
+19 -13
View File
@@ -6,8 +6,9 @@
-- doesn't exist yet. Never overwrites operator-tuned values.
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
jitter_magnitude, context_weight, similarity_weight, taste_weight,
context_time_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (profile) DO NOTHING;
-- name: ListWeightProfiles :many
@@ -15,23 +16,25 @@ SELECT * FROM recommendation_weight_profiles ORDER BY profile;
-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
context_time_weight = $10,
updated_at = now()
WHERE profile = $1
RETURNING *;
-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
mood_scale
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (singleton) DO NOTHING;
-- name: GetTasteTuning :one
@@ -43,6 +46,9 @@ UPDATE taste_tuning
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
enriched_tag_scale = $5,
era_scale = $6,
mood_scale = $7,
updated_at = now()
WHERE singleton = true
RETURNING *;
+39 -5
View File
@@ -6,11 +6,13 @@
-- One row per play in the decay-relevant window. completion is the play's
-- completion ratio (precomputed column when present, else duration_played /
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
-- is split into tags in Go. Quarantined tracks are excluded.
-- is split into tags in Go; release_date derives the decade for the era
-- facet (#1530). Quarantined tracks are excluded.
SELECT
t.id AS track_id,
t.artist_id,
t.genre,
a.release_date,
LEAST(GREATEST(
COALESCE(pe.completion_ratio,
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
@@ -18,6 +20,7 @@ SELECT
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS (
@@ -26,12 +29,13 @@ WHERE pe.user_id = $1
);
-- name: ListLikedTrackTasteInputsForUser :many
-- (track_id, artist_id, genre) for each track the user has explicitly
-- liked. Feeds the track-like bonus into the liked track's artist and
-- tags; track_id keys the enriched track_tags lookup (#1490).
SELECT t.id AS track_id, t.artist_id, t.genre
-- (track_id, artist_id, genre, release_date) for each track the user has
-- explicitly liked. Feeds the track-like bonus into the liked track's artist,
-- tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
JOIN albums a ON a.id = t.album_id
WHERE gl.user_id = $1;
-- name: ListLikedArtistIDsForUser :many
@@ -66,3 +70,33 @@ FROM taste_profile_tags
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2;
-- name: DeleteTasteProfileErasForUser :exec
DELETE FROM taste_profile_eras WHERE user_id = $1;
-- name: InsertTasteProfileEra :exec
INSERT INTO taste_profile_eras (user_id, era, weight)
VALUES ($1, $2, $3);
-- name: ListTasteProfileErasForUser :many
-- Top-weighted taste eras (#1530); consumed by the scorer's era term.
SELECT era, weight
FROM taste_profile_eras
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2;
-- name: DeleteTasteProfileMoodsForUser :exec
DELETE FROM taste_profile_moods WHERE user_id = $1;
-- name: InsertTasteProfileMood :exec
INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3);
-- name: ListTasteProfileMoodsForUser :many
-- Top-weighted taste moods (#1534); consumed by the scorer's mood term.
SELECT mood, weight
FROM taste_profile_moods
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2;
+11 -2
View File
@@ -7,9 +7,10 @@
-- Tracks eligible for tag enrichment: never processed (tag_source NULL)
-- or previously settled 'none' under an older provider version. Returns
-- the fields the provider chain needs — recording MBID (nullable) for
-- keyed lookups, plus title + artist name for name-based fallback.
-- keyed lookups, title + artist name for name-based fallback, and the
-- artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
-- $1 = current tag_sources_version, $2 = limit.
SELECT t.id, t.mbid, t.title, a.name AS artist_name
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
FROM tracks t
JOIN artists a ON a.id = t.artist_id
WHERE t.tag_source IS NULL
@@ -55,3 +56,11 @@ SELECT tt.track_id, tt.tag, tt.weight
FROM track_tags tt
JOIN general_likes gl ON gl.track_id = tt.track_id
WHERE gl.user_id = $1;
-- name: ListTrackTagsForTracks :many
-- Enriched tags for a set of candidate tracks (#1534), so the scorer can
-- derive each candidate's mood buckets at scoring time. One row per (track,
-- tag); the caller filters to mood words via the internal/mood vocabulary.
SELECT tt.track_id, tt.tag
FROM track_tags tt
WHERE tt.track_id = ANY($1::uuid[]);
+12
View File
@@ -62,6 +62,10 @@ var dataTables = []string{
// (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead.
"cover_art_provider_settings",
// Same reasoning for the tag-enrichment settings (#1490): truncate the
// per-provider rows (reconcile re-seeds registered ones), and reset the
// tag_sources_meta counter via UPDATE below rather than truncating it.
"tag_provider_settings",
// recsettings.New reconciles shipped defaults on every construction
// (#1250), so truncating gives each test pristine tuning values.
"recommendation_weight_profiles",
@@ -98,4 +102,12 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
}
// Same for the tag-sources counter (#1490) — clears cross-test version
// accumulation that would spuriously report version_bumped on a
// key-only change.
if _, err := pool.Exec(ctx,
"UPDATE tag_sources_meta SET current_version = 1, last_registered_providers_hash = ''",
); err != nil {
t.Fatalf("dbtest.ResetDB reset tag-sources version: %v", err)
}
}
+58
View File
@@ -0,0 +1,58 @@
// Package mood maps folksonomy tags (from the track_tags cache, #1490) to a
// small set of canonical mood buckets (#1534, milestone #160 Opt 2b). Both the
// taste-profile builder and the recommendation scorer classify a track's tags
// through Of() so a track's mood is derived the same way on both sides.
//
// The vocabulary is intentionally narrow: only words that clearly denote mood
// (not genre). A track with no mood-word tags contributes nothing — the mood
// facet is a supplement whose coverage grows with folksonomy enrichment
// (thin on MusicBrainz-only, richer once Last.fm is keyed).
package mood
import "strings"
// vocab maps a lowercased folksonomy tag to its canonical mood bucket. Several
// synonyms collapse to one bucket so "sad" / "melancholy" / "melancholic" all
// score the same dimension.
var vocab = map[string]string{
// melancholic
"melancholic": "melancholic", "melancholy": "melancholic", "sad": "melancholic",
"sombre": "melancholic", "somber": "melancholic", "wistful": "melancholic",
"bittersweet": "melancholic", "mournful": "melancholic",
// energetic
"energetic": "energetic", "energy": "energetic", "upbeat": "energetic",
"uplifting": "energetic", "party": "energetic", "anthemic": "energetic",
// chill
"chill": "chill", "chillout": "chill", "chilled": "chill", "mellow": "chill",
"relaxing": "chill", "relaxed": "chill", "calm": "chill", "laid-back": "chill",
"laidback": "chill", "soothing": "chill",
// aggressive
"aggressive": "aggressive", "angry": "aggressive", "intense": "aggressive",
"heavy": "aggressive", "brutal": "aggressive",
// dark
"dark": "dark", "moody": "dark", "brooding": "dark", "haunting": "dark", "eerie": "dark",
// dreamy
"dreamy": "dreamy", "ethereal": "dreamy", "atmospheric": "dreamy",
"ambient": "dreamy", "hypnotic": "dreamy",
// happy
"happy": "happy", "cheerful": "happy", "feel good": "happy", "feel-good": "happy",
"feelgood": "happy", "joyful": "happy", "fun": "happy",
// romantic
"romantic": "romantic", "sensual": "romantic", "sexy": "romantic", "sentimental": "romantic",
}
// Of returns the distinct canonical mood buckets present in tags, lowercasing +
// trimming each tag before lookup. Returns nil when no tag maps to a mood, so
// callers get no mood signal for a track with only genre/descriptive tags.
func Of(tags []string) []string {
var out []string
seen := make(map[string]bool)
for _, t := range tags {
key := strings.ToLower(strings.TrimSpace(t))
if m, ok := vocab[key]; ok && !seen[m] {
seen[m] = true
out = append(out, m)
}
}
return out
}
+43
View File
@@ -0,0 +1,43 @@
package mood
import (
"sort"
"testing"
)
func TestOf(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{"synonyms collapse", []string{"sad", "melancholy", "melancholic"}, []string{"melancholic"}},
{"case + whitespace", []string{" CHILL ", "Mellow"}, []string{"chill"}},
{"multiple buckets", []string{"energetic", "dark"}, []string{"dark", "energetic"}},
{"genre-only → none", []string{"rock", "shoegaze", "post-punk"}, nil},
{"empty", nil, nil},
}
for _, c := range cases {
got := Of(c.in)
sort.Strings(got)
want := append([]string(nil), c.want...)
sort.Strings(want)
if len(got) != len(want) {
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
continue
}
for i := range got {
if got[i] != want[i] {
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
break
}
}
}
}
func TestOf_Dedupes(t *testing.T) {
// Three tags mapping to the same bucket yield one entry.
if got := Of([]string{"chill", "mellow", "relaxed"}); len(got) != 1 || got[0] != "chill" {
t.Errorf("Of dedupe = %v, want [chill]", got)
}
}
+28 -17
View File
@@ -65,7 +65,16 @@ func (w *Writer) RecordPlayStarted(
clientID string,
at time.Time,
) (StartedResult, error) {
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", at)
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", "", at)
}
// strPtrOrNil maps an empty string to a NULL text column (nullable pointer),
// mirroring the clientID/source nil-on-empty pattern used above.
func strPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}
// systemPlaylistSources are the play_events.source values that count
@@ -136,7 +145,7 @@ func lookupSystemPickKind(
func (w *Writer) RecordPlayStartedWithSource(
ctx context.Context,
userID, trackID pgtype.UUID,
clientID, source string,
clientID, source, deviceClass string,
at time.Time,
) (StartedResult, error) {
var out StartedResult
@@ -165,13 +174,14 @@ func (w *Writer) RecordPlayStartedWithSource(
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
DeviceClass: strPtrOrNil(deviceClass),
})
if err != nil {
return err
@@ -389,7 +399,7 @@ func (w *Writer) RecordSyntheticCompletedPlay(
func (w *Writer) RecordOfflinePlay(
ctx context.Context,
userID, trackID pgtype.UUID,
clientID, source string,
clientID, source, deviceClass string,
at time.Time,
durationPlayedMs int32,
) error {
@@ -441,13 +451,14 @@ func (w *Writer) RecordOfflinePlay(
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
DeviceClass: strPtrOrNil(deviceClass),
})
if err != nil {
return err
+9 -9
View File
@@ -226,10 +226,10 @@ func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
f := newFixture(t, 200_000)
ctx := context.Background()
at := time.Now().UTC()
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
t.Fatalf("first offline: %v", err)
}
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
t.Fatalf("replay offline: %v", err)
}
var count int
@@ -444,7 +444,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
f := newFixture(t, 200_000)
now := time.Now().UTC()
if _, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", now,
context.Background(), f.user, f.track, "c", "for_you", "", now,
); err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
@@ -460,7 +460,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
// Re-playing the same track stays a set (no duplicate append).
if _, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", now.Add(time.Minute),
context.Background(), f.user, f.track, "c", "for_you", "", now.Add(time.Minute),
); err != nil {
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
}
@@ -511,7 +511,7 @@ func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "fresh")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
context.Background(), f.user, f.track, "c", "for_you", "", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
@@ -530,7 +530,7 @@ func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T)
// out, or no snapshot exists) stays unattributed rather than guessing.
f := newFixture(t, 200_000)
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
context.Background(), f.user, f.track, "c", "for_you", "", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
@@ -552,7 +552,7 @@ func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "taste")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
context.Background(), f.user, f.track, "c", "discover", "", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
@@ -572,7 +572,7 @@ func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "discover", "dormant")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
context.Background(), f.user, f.track, "c", "discover", "", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
@@ -593,7 +593,7 @@ func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
seedSystemSnapshot(t, f, "for_you", "taste")
at := time.Now().UTC().Add(-time.Hour)
if err := f.w.RecordOfflinePlay(
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
context.Background(), f.user, f.track, "c", "for_you", "", at, 180_000,
); err != nil {
t.Fatalf("RecordOfflinePlay: %v", err)
}
+6
View File
@@ -215,6 +215,9 @@ var (
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
// while passive avoidance (negative) gently demotes.
TasteWeight: 1.5,
// Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts
// uniform with radio pending trend data.
ContextTimeWeight: 1.0,
}
systemTasteConfig = taste.DefaultConfig()
)
@@ -543,6 +546,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
// deep slice of the user's top taste-profile artists into the pool
// (#796 phase 2b). Empty for cold-start users (no profile yet).
TasteOverlap: 80,
// Household co-play (#1533): a deeper collaborative slice for the
// taste surfaces. Empty on single-user servers.
UserCoplay: 40,
}
}
+61 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
)
// LoadCandidates fetches the candidate pool for radio scoring. Combines
@@ -41,6 +42,11 @@ func LoadCandidates(
return nil, err
}
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(rows))
for _, r := range rows {
var lpt *time.Time
@@ -57,7 +63,11 @@ func LoadCandidates(
PlayCount: int(r.PlayCount),
SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore,
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
// Fallback path: mood is scored only in the primary
// (similarity) loader — loading per-candidate tags over this
// near-whole-library pool isn't worth it (nil moods → 0).
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil),
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
},
})
}
@@ -76,6 +86,10 @@ type CandidateSourceLimits struct {
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
// users have an empty profile, so it contributes nothing anyway).
TasteOverlap int
// UserCoplay (#1533): tracks by artists co-played across the instance
// with the seed's artist (source='user_cooccurrence'). Empty on
// single-user servers, so it contributes nothing there.
UserCoplay int
}
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
@@ -87,6 +101,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
LikesOverlap: 20,
RandomFill: 30,
TasteOverlap: 20,
UserCoplay: 20,
}
}
@@ -119,6 +134,7 @@ func LoadCandidatesFromSimilarity(
Limit_4: int32(limits.LikesOverlap),
Limit_5: int32(limits.RandomFill),
Limit_6: int32(limits.TasteOverlap),
Limit_7: int32(limits.UserCoplay),
})
if err != nil {
return nil, err
@@ -134,6 +150,20 @@ func LoadCandidatesFromSimilarity(
return nil, err
}
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
if err != nil {
return nil, err
}
trackIDs := make([]pgtype.UUID, 0, len(rows))
for _, r := range rows {
trackIDs = append(trackIDs, r.Track.ID)
}
moods, err := loadCandidateMoods(ctx, q, trackIDs)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(rows))
for _, r := range rows {
var lpt *time.Time
@@ -158,13 +188,42 @@ func LoadCandidatesFromSimilarity(
SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore,
SimilarityScore: simScore,
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
TasteMatchScore: profile.Match(
r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, moods[r.Track.ID]),
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
},
})
}
return out, nil
}
// loadCandidateMoods fetches the enriched tags for the given candidate tracks
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
// are absent from the map (→ no mood signal). Empty input short-circuits.
func loadCandidateMoods(
ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID,
) (map[pgtype.UUID][]string, error) {
if len(trackIDs) == 0 {
return map[pgtype.UUID][]string{}, nil
}
rows, err := q.ListTrackTagsForTracks(ctx, trackIDs)
if err != nil {
return nil, err
}
tagsByTrack := make(map[pgtype.UUID][]string)
for _, r := range rows {
tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag)
}
out := make(map[pgtype.UUID][]string, len(tagsByTrack))
for id, tags := range tagsByTrack {
if m := mood.Of(tags); len(m) > 0 {
out[id] = m
}
}
return out, nil
}
// loadContextualLikesByTrack fetches the user's active contextual_likes in
// one query and groups them by track_id. Rows whose session_vector fails
// to unmarshal are skipped with no error (don't poison scoring over one
+75
View File
@@ -0,0 +1,75 @@
package recommendation
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// contextAffinityShrinkage is the pseudo-count that pulls a low-play artist's
// cell-share toward the user's baseline, so an artist with one or two plays
// can't swing its affinity to ±1 on noise. At k plays the estimate sits
// halfway between the raw cell-share and the baseline.
const contextAffinityShrinkage = 5.0
// ContextAffinity is the read-side map of per-artist time-of-day/weekday
// affinity for the CURRENT context (#1531): artist_id → score in [-1, +1].
// Absent artists (no play history) score 0, so cold-start candidates stay
// neutral. The zero value is a valid all-neutral affinity.
type ContextAffinity struct {
byArtist map[pgtype.UUID]float64
}
// Affinity returns the artist's current-context affinity, or 0 if unknown.
func (c ContextAffinity) Affinity(artistID pgtype.UUID) float64 {
return c.byArtist[artistID]
}
// LoadContextAffinity computes each artist's affinity for the user's CURRENT
// context cell — daypart × weekday, narrowed by deviceClass when non-empty
// (#1551; radio passes the current device, the daily mixes pass "" for a
// device-agnostic cell). For every artist with completed plays in the window it
// compares the share of that artist's plays that fall in the current cell
// against the user's overall baseline share, shrinking sparse artists toward
// the baseline. Returns an empty (all-neutral) affinity when the user has no
// plays.
func LoadContextAffinity(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, deviceClass string,
) (ContextAffinity, error) {
rows, err := q.ListArtistContextPlayCountsForUser(ctx, dbq.ListArtistContextPlayCountsForUserParams{
ID: userID,
Column2: deviceClass,
})
if err != nil {
return ContextAffinity{}, err
}
var totalPlays, cellPlays int64
for _, r := range rows {
totalPlays += r.TotalPlays
cellPlays += r.CellPlays
}
out := ContextAffinity{byArtist: make(map[pgtype.UUID]float64, len(rows))}
if totalPlays == 0 {
return out, nil
}
baseline := float64(cellPlays) / float64(totalPlays)
for _, r := range rows {
out.byArtist[r.ArtistID] = contextAffinity(
float64(r.CellPlays), float64(r.TotalPlays), baseline, contextAffinityShrinkage)
}
return out, nil
}
// contextAffinity returns an artist's shrunk cell-share minus the user's
// baseline share, clamped to [-1, 1]. The shrinkage pseudo-count k pulls
// low-play artists toward the baseline (→ 0 affinity) so noise can't dominate;
// a heavily-played artist keeps close to its raw over/under-representation.
func contextAffinity(cellPlays, totalPlays, baseline, k float64) float64 {
if totalPlays == 0 {
return 0
}
shrunk := (cellPlays + baseline*k) / (totalPlays + k)
return clampUnit(shrunk - baseline)
}
@@ -0,0 +1,87 @@
package recommendation
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func insertPlayEventDevice(
t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, device string,
) {
t.Helper()
ctx := context.Background()
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'ctx-dev-test') RETURNING id`,
userID, startedAt).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped, device_class)
VALUES ($1, $2, $3, $4, false, $5)`,
userID, trackID, sessionID, startedAt, device); err != nil {
t.Fatalf("insert play_event: %v", err)
}
}
// TestLoadContextAffinity_DeviceNarrowing seeds one artist played only on
// mobile and another only on web (all in the current time cell), and verifies
// the current-device parameter narrows the affinity cell (#1551): on 'mobile'
// the mobile artist out-scores the web artist and vice-versa, while the
// device-agnostic (”) pass treats them equally.
func TestLoadContextAffinity_DeviceNarrowing(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
u := seedUser(t, pool, "ctx-dev")
aMobile := seedArtist(t, pool, "MobileArtist", "")
aWeb := seedArtist(t, pool, "WebArtist", "")
alM := seedAlbumForArtist(t, pool, aMobile.ID, "AlbM")
tM := seedTrackOnAlbum(t, pool, alM.ID, aMobile.ID, "TrkM")
alW := seedAlbumForArtist(t, pool, aWeb.ID, "AlbW")
tW := seedTrackOnAlbum(t, pool, alW.ID, aWeb.ID, "TrkW")
now := time.Now()
for i := 0; i < 5; i++ {
insertPlayEventDevice(t, pool, u.ID, tM.ID, now, "mobile")
insertPlayEventDevice(t, pool, u.ID, tW.ID, now, "web")
}
mob, err := LoadContextAffinity(ctx, q, u.ID, "mobile")
if err != nil {
t.Fatalf("mobile: %v", err)
}
if mob.Affinity(aMobile.ID) <= mob.Affinity(aWeb.ID) {
t.Errorf("on mobile, mobile artist (%.3f) should out-score web artist (%.3f)",
mob.Affinity(aMobile.ID), mob.Affinity(aWeb.ID))
}
web, err := LoadContextAffinity(ctx, q, u.ID, "web")
if err != nil {
t.Fatalf("web: %v", err)
}
if web.Affinity(aWeb.ID) <= web.Affinity(aMobile.ID) {
t.Errorf("on web, web artist (%.3f) should out-score mobile artist (%.3f)",
web.Affinity(aWeb.ID), web.Affinity(aMobile.ID))
}
// Device-agnostic ('' ): all plays fall in the same time cell, so the two
// artists are treated identically — the device dimension is what separates
// them above.
agn, err := LoadContextAffinity(ctx, q, u.ID, "")
if err != nil {
t.Fatalf("agnostic: %v", err)
}
if agn.Affinity(aMobile.ID) != agn.Affinity(aWeb.ID) {
t.Errorf("device-agnostic affinities should match, got mobile=%.3f web=%.3f",
agn.Affinity(aMobile.ID), agn.Affinity(aWeb.ID))
}
}
+57
View File
@@ -0,0 +1,57 @@
package recommendation
import (
"testing"
"time"
)
func TestContextAffinity(t *testing.T) {
const baseline = 0.4 // 40% of the user's plays fall in the current cell
const k = contextAffinityShrinkage
// Heavy history, over-represented in the current cell → positive.
if a := contextAffinity(80, 100, baseline, k); a <= 0 {
t.Errorf("over-represented artist affinity = %.3f, want positive", a)
}
// Heavy history, under-represented → negative.
if a := contextAffinity(10, 100, baseline, k); a >= 0 {
t.Errorf("under-represented artist affinity = %.3f, want negative", a)
}
// A sparse artist (1/1) shrinks toward the baseline, so its affinity is
// smaller than a heavily-played artist with the same raw cell-share.
sparse := contextAffinity(1, 1, baseline, k)
heavy := contextAffinity(100, 100, baseline, k)
if sparse >= heavy {
t.Errorf("sparse (%.3f) should shrink below heavy (%.3f)", sparse, heavy)
}
// No plays → neutral.
if a := contextAffinity(0, 0, baseline, k); a != 0 {
t.Errorf("no plays affinity = %.3f, want 0", a)
}
// Result stays within [-1, 1].
for _, tc := range [][2]float64{{100, 100}, {0, 100}, {50, 50}} {
a := contextAffinity(tc[0], tc[1], baseline, k)
if a < -1 || a > 1 {
t.Errorf("affinity out of [-1,1]: %.3f", a)
}
}
}
func TestScore_ContextTermAddsAndSubtracts(t *testing.T) {
now := time.Now()
zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude
w := ScoringWeights{ContextTimeWeight: 2.0} // all other weights 0
pos := Score(ScoringInputs{ContextAffinityScore: 1.0}, w, now, zeroJitter)
if !almostEq(pos, 2.0) {
t.Errorf("positive context affinity: Score = %.3f, want 2.0", pos)
}
neg := Score(ScoringInputs{ContextAffinityScore: -1.0}, w, now, zeroJitter)
if !almostEq(neg, -2.0) {
t.Errorf("negative context affinity: Score = %.3f, want -2.0 (demotes)", neg)
}
off := Score(ScoringInputs{ContextAffinityScore: 1.0}, ScoringWeights{}, now, zeroJitter)
if !almostEq(off, 0.0) {
t.Errorf("ContextTimeWeight 0: Score = %.3f, want 0 (no effect)", off)
}
}
+17 -8
View File
@@ -24,19 +24,26 @@ type ScoringInputs struct {
// user's taste, negative reflects passive avoidance, 0 when there's no
// profile signal (cold start / artist+tags absent from the profile).
TasteMatchScore float64
// ContextAffinityScore is the candidate artist's time-of-day/weekday
// affinity for the CURRENT context (#1531), in [-1, +1]: positive when the
// artist's plays concentrate in the current daypart × weekday-type cell
// more than the user's baseline, negative when under-represented, 0 when
// there's no history (cold-start neutral).
ContextAffinityScore float64
}
// ScoringWeights are the operator-tunable knobs. Defaults live in
// config.RecommendationConfig and are propagated here per request.
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
// Score computes the weighted-shuffle score per spec §6:
@@ -48,6 +55,7 @@ type ScoringWeights struct {
// + contextual_match_score * ContextWeight
// + similarity_score * SimilarityWeight
// + taste_match_score * TasteWeight
// + context_affinity_score * ContextTimeWeight
// + small_random_jitter
//
// Higher score = more likely to surface. rng is a function returning a
@@ -63,6 +71,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
s += in.ContextualMatchScore * w.ContextWeight
s += in.SimilarityScore * w.SimilarityWeight
s += in.TasteMatchScore * w.TasteWeight
s += in.ContextAffinityScore * w.ContextTimeWeight
s += (rng()*2 - 1) * w.JitterMagnitude
return s
}
+5
View File
@@ -13,6 +13,11 @@ type SessionVector struct {
Artists []string `json:"artists"`
Tags map[string]int `json:"tags"`
RecentTrackIDs []string `json:"recent_track_ids"`
// DeviceClass is the current request's device (#1551), set by the radio
// handler from the user's latest play; drives the device dimension of the
// context-affinity term. Empty (omitted) for the daily mixes and for the
// snapshot stored on play_events, so it never narrows those.
DeviceClass string `json:"device_class,omitempty"`
}
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
+70 -8
View File
@@ -2,6 +2,7 @@ package recommendation
import (
"context"
"fmt"
"math"
"github.com/jackc/pgx/v5/pgtype"
@@ -10,24 +11,37 @@ import (
)
// Taste-match tuning. The taste profile (written by internal/taste) holds
// signed, unbounded artist/tag weights; these scales squash them into a
// signed, unbounded artist/tag/era weights; these scales squash them into a
// bounded [-1, +1] match via tanh, so one outlier artist can't compress the
// rest toward zero (as a per-user max-normalisation would). A weight at the
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
//
// The era (#1530) and mood (#1534) terms are added on TOP of artist+tag
// (shares don't sum to 1) so they're pure nudges that never weaken the existing
// artist/tag signal when a track lacks a date or mood tags; clampUnit bounds
// the combined result. Their shares are small — both are coarse, partial-
// coverage signals — and re-bakeable if the lab shows they should carry more.
const (
tasteArtistScale = 4.0
tasteTagScale = 3.0
tasteEraScale = 4.0
tasteMoodScale = 3.0
tasteArtistShare = 0.7
tasteTagShare = 0.3
tasteEraShare = 0.15
tasteMoodShare = 0.12
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
)
// TasteProfile is the read-side view of a user's learned taste: signed
// weights over artists and genre tags. The zero value (and any unknown
// artist/tag) contributes 0, so cold-start users get no taste effect.
// TasteProfile is the read-side view of a user's learned taste: signed weights
// over artists, genre tags, decade/era buckets, and mood buckets. The zero
// value (and any unknown key) contributes 0, so cold-start users get no taste
// effect.
type TasteProfile struct {
artists map[pgtype.UUID]float64
tags map[string]float64
eras map[string]float64
moods map[string]float64
}
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
@@ -46,9 +60,23 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
if err != nil {
return TasteProfile{}, err
}
eras, err := q.ListTasteProfileErasForUser(ctx, dbq.ListTasteProfileErasForUserParams{
UserID: userID, Limit: tasteProfileLimit,
})
if err != nil {
return TasteProfile{}, err
}
moods, err := q.ListTasteProfileMoodsForUser(ctx, dbq.ListTasteProfileMoodsForUserParams{
UserID: userID, Limit: tasteProfileLimit,
})
if err != nil {
return TasteProfile{}, err
}
p := TasteProfile{
artists: make(map[pgtype.UUID]float64, len(arts)),
tags: make(map[string]float64, len(tags)),
eras: make(map[string]float64, len(eras)),
moods: make(map[string]float64, len(moods)),
}
for _, a := range arts {
p.artists[a.ArtistID] = a.Weight
@@ -56,13 +84,23 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
for _, t := range tags {
p.tags[t.Tag] = t.Weight
}
for _, e := range eras {
p.eras[e.Era] = e.Weight
}
for _, m := range moods {
p.moods[m.Mood] = m.Weight
}
return p, nil
}
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
// the artist's taste weight and the average of its genre tags' weights, each
// tanh-squashed. Absent artist/tags contribute 0.
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
// the artist's taste weight, the average of its genre tags' weights, its
// decade/era weight, and the average of its mood buckets' weights, each
// tanh-squashed. moods are the candidate's canonical mood buckets (from
// internal/mood; nil when unknown). Absent artist/tags/era/moods contribute 0.
func (p TasteProfile) Match(
artistID pgtype.UUID, genre *string, releaseDate pgtype.Date, moods []string,
) float64 {
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
var tg float64
@@ -76,7 +114,31 @@ func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
}
}
return clampUnit(tasteArtistShare*a + tasteTagShare*tg)
var er float64
if decade := decadeOf(releaseDate); decade != "" {
er = math.Tanh(p.eras[decade] / tasteEraScale)
}
var mo float64
if len(moods) > 0 {
var sum float64
for _, m := range moods {
sum += p.moods[m]
}
mo = math.Tanh((sum / float64(len(moods))) / tasteMoodScale)
}
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er + tasteMoodShare*mo)
}
// decadeOf maps an album release date to a decade bucket ("1990s"), or "" for
// an absent date. Mirrors the taste builder's helper (kept local to avoid a
// cross-package dependency) so a candidate's era is derived exactly as learned.
func decadeOf(d pgtype.Date) string {
if !d.Valid {
return ""
}
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
}
// clampUnit constrains x to [-1, 1].
+60 -8
View File
@@ -17,6 +17,14 @@ func uuidN(n byte) pgtype.UUID {
func strPtr(s string) *string { return &s }
// noDate is an absent release date — the era term contributes 0.
func noDate() pgtype.Date { return pgtype.Date{} }
// dateInYear builds a valid release date in the given year (era = its decade).
func dateInYear(year int) pgtype.Date {
return pgtype.Date{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}
}
func TestTasteProfile_Match(t *testing.T) {
loved := uuidN(1)
disliked := uuidN(2)
@@ -26,32 +34,76 @@ func TestTasteProfile_Match(t *testing.T) {
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
}
if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 {
if m := p.Match(loved, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
}
if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 {
if m := p.Match(disliked, strPtr("Noise"), noDate(), nil); m >= -0.5 {
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
}
if m := p.Match(unknown, nil); m != 0 {
if m := p.Match(unknown, nil, noDate(), nil); m != 0 {
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
}
// Artist dominates (0.7 share): loved artist with an unknown tag is still
// clearly positive.
if m := p.Match(loved, strPtr("Unheard")); m <= 0 {
if m := p.Match(loved, strPtr("Unheard"), noDate(), nil); m <= 0 {
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
}
// Output stays within [-1, 1] even with saturated inputs.
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
m := p.Match(a, strPtr("Jazz"))
m := p.Match(a, strPtr("Jazz"), dateInYear(1994), nil)
if m < -1 || m > 1 {
t.Errorf("Match out of [-1,1]: %.3f", m)
}
}
}
// TestTasteProfile_EraTerm verifies the decade facet nudges the match: with
// artist + genre held neutral, a loved era lifts the score and a disliked era
// lowers it, while an undated track is unaffected.
func TestTasteProfile_EraTerm(t *testing.T) {
art := uuidN(1)
p := TasteProfile{
artists: map[pgtype.UUID]float64{},
tags: map[string]float64{},
eras: map[string]float64{"1990s": 8.0, "1980s": -8.0},
}
loved := p.Match(art, nil, dateInYear(1994), nil)
if loved <= 0 {
t.Errorf("loved era (1990s) = %.3f, want positive", loved)
}
disliked := p.Match(art, nil, dateInYear(1987), nil)
if disliked >= 0 {
t.Errorf("disliked era (1980s) = %.3f, want negative", disliked)
}
if m := p.Match(art, nil, noDate(), nil); m != 0 {
t.Errorf("undated track = %.3f, want 0 (no era contribution)", m)
}
}
// TestTasteProfile_MoodTerm verifies the mood facet nudges the match: with
// artist/genre/era neutral, a loved mood lifts the score and a disliked mood
// lowers it, while a track with no mood buckets is unaffected.
func TestTasteProfile_MoodTerm(t *testing.T) {
art := uuidN(1)
p := TasteProfile{
artists: map[pgtype.UUID]float64{},
tags: map[string]float64{},
moods: map[string]float64{"chill": 8.0, "aggressive": -8.0},
}
if m := p.Match(art, nil, noDate(), []string{"chill"}); m <= 0 {
t.Errorf("loved mood (chill) = %.3f, want positive", m)
}
if m := p.Match(art, nil, noDate(), []string{"aggressive"}); m >= 0 {
t.Errorf("disliked mood (aggressive) = %.3f, want negative", m)
}
if m := p.Match(art, nil, noDate(), nil); m != 0 {
t.Errorf("no moods = %.3f, want 0 (no mood contribution)", m)
}
}
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
var p TasteProfile // zero value: nil maps
if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 {
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994), nil); m != 0 {
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
}
}
@@ -100,10 +152,10 @@ func TestLoadTasteProfile_RoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("load: %v", err)
}
if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 {
if m := p.Match(art.ID, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
}
if m := p.Match(uuidN(9), nil); m != 0 {
if m := p.Match(uuidN(9), nil, noDate(), nil); m != 0 {
t.Errorf("absent artist Match = %.3f, want 0", m)
}
}
+13
View File
@@ -67,6 +67,10 @@ var weightFields = map[string]weightField{
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
},
"context_time_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.ContextTimeWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextTimeWeight = v },
},
}
// applyWeightPatch validates and applies a partial update, returning
@@ -129,6 +133,12 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
target = &next.EngagementNeutral
case "engagement_full":
target = &next.EngagementFull
case "enriched_tag_scale":
target = &next.EnrichedTagScale
case "era_scale":
target = &next.EraScale
case "mood_scale":
target = &next.MoodScale
default:
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
@@ -174,5 +184,8 @@ func diffTaste(a, b TasteTuning) []fieldChange {
add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
add("engagement_full", a.EngagementFull, b.EngagementFull)
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
add("era_scale", a.EraScale, b.EraScale)
add("mood_scale", a.MoodScale, b.MoodScale)
return out
}
+70 -43
View File
@@ -38,28 +38,36 @@ const (
)
// TasteTuning is the tunable subset of taste.Config: the engagement
// half-life and the completion→engagement curve points.
// half-life, the completion→engagement curve points, the enriched-tag
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
// era-facet weight (how strongly a decade-play imprints, #1530).
type TasteTuning struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
// ShippedRadioWeights are the shipped radio-profile defaults (moved
// here from config.RecommendationConfig — YAML is bootstrap-only,
// rule: config in UI). Radio is seed-directed (the user picked a
// direction), so taste is a lighter nudge than in the daily mixes.
// ContextTimeWeight starts uniform (1.0) across both profiles pending
// trend data (#1531); split them once the metrics view justifies it.
func ShippedRadioWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
TasteWeight: 1.0,
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
TasteWeight: 1.0,
ContextTimeWeight: 1.0,
}
}
@@ -67,14 +75,15 @@ func ShippedRadioWeights() recommendation.ScoringWeights {
// Must stay in sync with the pre-push literal in playlists/system.go.
func ShippedDailyMixWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
TasteWeight: 1.5,
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
TasteWeight: 1.5,
ContextTimeWeight: 1.0,
}
}
@@ -86,6 +95,9 @@ func ShippedTasteTuning() TasteTuning {
EngagementHardSkip: d.Engagement.HardSkip,
EngagementNeutral: d.Engagement.NeutralCompletion,
EngagementFull: d.Engagement.FullCompletion,
EnrichedTagScale: d.EnrichedTagScale,
EraScale: d.EraScale,
MoodScale: d.MoodScale,
}
}
@@ -133,6 +145,9 @@ func (s *Service) reconcile(ctx context.Context) error {
EngagementHardSkip: st.EngagementHardSkip,
EngagementNeutral: st.EngagementNeutral,
EngagementFull: st.EngagementFull,
EnrichedTagScale: st.EnrichedTagScale,
EraScale: st.EraScale,
MoodScale: st.MoodScale,
}); err != nil {
return fmt.Errorf("seed taste tuning: %w", err)
}
@@ -156,6 +171,9 @@ func (s *Service) reconcile(ctx context.Context) error {
EngagementHardSkip: tt.EngagementHardSkip,
EngagementNeutral: tt.EngagementNeutral,
EngagementFull: tt.EngagementFull,
EnrichedTagScale: tt.EnrichedTagScale,
EraScale: tt.EraScale,
MoodScale: tt.MoodScale,
}
s.mu.Unlock()
@@ -205,6 +223,9 @@ func (s *Service) TasteConfig() taste.Config {
NeutralCompletion: t.EngagementNeutral,
FullCompletion: t.EngagementFull,
}
cfg.EnrichedTagScale = t.EnrichedTagScale
cfg.EraScale = t.EraScale
cfg.MoodScale = t.MoodScale
return cfg
}
@@ -301,6 +322,9 @@ func (s *Service) persistTaste(
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}); err != nil {
return fmt.Errorf("update taste tuning: %w", err)
}
@@ -334,41 +358,44 @@ func (s *Service) audit(
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
return dbq.UpsertWeightProfileDefaultsParams{
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
ContextTimeWeight: w.ContextTimeWeight,
}
}
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
return dbq.UpdateWeightProfileParams{
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
ContextTimeWeight: w.ContextTimeWeight,
}
}
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: r.BaseWeight,
LikeBoost: r.LikeBoost,
RecencyWeight: r.RecencyWeight,
SkipPenalty: r.SkipPenalty,
JitterMagnitude: r.JitterMagnitude,
ContextWeight: r.ContextWeight,
SimilarityWeight: r.SimilarityWeight,
TasteWeight: r.TasteWeight,
BaseWeight: r.BaseWeight,
LikeBoost: r.LikeBoost,
RecencyWeight: r.RecencyWeight,
SkipPenalty: r.SkipPenalty,
JitterMagnitude: r.JitterMagnitude,
ContextWeight: r.ContextWeight,
SimilarityWeight: r.SimilarityWeight,
TasteWeight: r.TasteWeight,
ContextTimeWeight: r.ContextTimeWeight,
}
}
+61
View File
@@ -182,6 +182,67 @@ func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) {
}
}
func TestUpdateTaste_EnrichedTagScale(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
// In-range update persists into cache + the assembled taste config.
if err := s.UpdateTaste(context.Background(),
map[string]float64{"enriched_tag_scale": 0.8}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
if got := s.Taste().EnrichedTagScale; got != 0.8 {
t.Errorf("Taste().EnrichedTagScale = %v, want 0.8", got)
}
if got := s.TasteConfig().EnrichedTagScale; got != 0.8 {
t.Errorf("TasteConfig().EnrichedTagScale = %v, want 0.8", got)
}
// Out of [0,1] rejects.
if err := s.UpdateTaste(context.Background(),
map[string]float64{"enriched_tag_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
}
}
func TestUpdateTaste_EraScale(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
// In-range update persists into cache + the assembled taste config.
if err := s.UpdateTaste(context.Background(),
map[string]float64{"era_scale": 0.8}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
if got := s.Taste().EraScale; got != 0.8 {
t.Errorf("Taste().EraScale = %v, want 0.8", got)
}
if got := s.TasteConfig().EraScale; got != 0.8 {
t.Errorf("TasteConfig().EraScale = %v, want 0.8", got)
}
// Out of [0,1] rejects.
if err := s.UpdateTaste(context.Background(),
map[string]float64{"era_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
}
}
func TestUpdateTaste_MoodScale(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if err := s.UpdateTaste(context.Background(),
map[string]float64{"mood_scale": 0.8}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
if got := s.Taste().MoodScale; got != 0.8 {
t.Errorf("Taste().MoodScale = %v, want 0.8", got)
}
if got := s.TasteConfig().MoodScale; got != 0.8 {
t.Errorf("TasteConfig().MoodScale = %v, want 0.8", got)
}
if err := s.UpdateTaste(context.Background(),
map[string]float64{"mood_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
}
}
func TestReset_RestoresShippedAndAudits(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
+3
View File
@@ -171,6 +171,9 @@ func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
if r.Mbid != nil {
ref.MBID = *r.Mbid
}
if r.ArtistMbid != nil {
ref.ArtistMBID = *r.ArtistMbid
}
oc, eerr := e.EnrichTrack(ctx, r.ID, ref)
if eerr != nil {
e.logger.Warn("tags: batch entry failed", "track_id", uuidString(r.ID), "err", eerr)
+4 -1
View File
@@ -32,11 +32,14 @@ type Tag struct {
// TrackRef is the lookup key passed to a provider. MBID is the track's
// recording MBID (preferred by MBID-keyed providers); ArtistName + Title
// are the always-populated fallback for name-based providers (Last.fm).
// are the always-populated fallback for name-based providers (Last.fm);
// ArtistMBID (nullable) enables MusicBrainz's artist-tag fallback (#1519)
// when the recording itself is untagged or MBID-less.
type TrackRef struct {
MBID string
ArtistName string
Title string
ArtistMBID string
}
// Provider is the base shape every tag source implements. Providers
+49 -14
View File
@@ -17,13 +17,18 @@ const (
mbMinPeriod = 1100 * time.Millisecond
// mbUserAgent identifies the app — MusicBrainz rejects requests without one.
mbUserAgent = "Minstrel/1.0 ( https://git.fabledsword.com/bvandeusen/minstrel )"
// artistTagWeightFactor down-weights artist-level fallback tags relative
// to recording-specific tags — the artist's overall character is a coarser
// signal than a tag on the exact recording (#1519).
artistTagWeightFactor = 0.6
)
// musicbrainzProvider fetches recording-level folksonomy tags from the
// MusicBrainz web service. Keyless (the public endpoint needs no auth) and
// on by default — the always-available baseline source. MBID-only: a track
// with no recording MBID yields ErrNotFound (name search is ambiguous for
// recordings and deferred to name-based providers like Last.fm).
// musicbrainzProvider fetches folksonomy tags from the MusicBrainz web
// service. Keyless (the public endpoint needs no auth) and on by default —
// the always-available baseline source. Prefers recording-level tags by the
// track's recording MBID; when the recording is untagged or MBID-less it
// falls back to the artist's tags by artist MBID (#1519), down-weighted as a
// coarser signal. Name-based lookup is deferred to providers like Last.fm.
type musicbrainzProvider struct {
enabled atomic.Bool
client *httpClient
@@ -61,24 +66,54 @@ type mbTag struct {
Name string `json:"name"`
}
// FetchTrackTags looks up the recording's tags by MBID. Returns ErrNotFound
// when disabled, when the track has no MBID, or when MusicBrainz has no tags.
// FetchTrackTags looks up recording tags by MBID, then falls back to artist
// tags by artist MBID when the recording is untagged or MBID-less. Returns
// ErrNotFound when disabled or when neither level yields tags. A transient
// error at the recording step is returned as-is (retry) rather than silently
// masked by the artist fallback.
func (p *musicbrainzProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error) {
if !p.enabled.Load() || ref.MBID == "" {
if !p.enabled.Load() {
return nil, ErrNotFound
}
q := url.Values{"inc": {"tags"}, "fmt": {"json"}}
full := mbBaseURL + "/recording/" + url.PathEscape(ref.MBID) + "?" + q.Encode()
if ref.MBID != "" {
tags, err := p.fetchEntityTags(ctx, "recording", ref.MBID, 1.0)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if len(tags) > 0 {
return tags, nil
}
}
if ref.ArtistMBID != "" {
tags, err := p.fetchEntityTags(ctx, "artist", ref.ArtistMBID, artistTagWeightFactor)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if len(tags) > 0 {
return tags, nil
}
}
return nil, ErrNotFound
}
// fetchEntityTags loads folksonomy tags for a MusicBrainz entity ("recording"
// or "artist") by MBID and scales the normalized weights by `scale`. Returns
// an empty slice (not ErrNotFound) when the entity exists but is untagged, so
// the caller can decide whether to fall through to the next level.
func (p *musicbrainzProvider) fetchEntityTags(ctx context.Context, entity, mbid string, scale float64) ([]Tag, error) {
q := url.Values{"inc": {"tags"}, "fmt": {"json"}}
full := mbBaseURL + "/" + entity + "/" + url.PathEscape(mbid) + "?" + q.Encode()
var resp mbTagsResponse
if err := p.client.getJSON(ctx, full, &resp); err != nil {
return nil, err
}
out := normalizeMBTags(resp.Tags)
if len(out) == 0 {
return nil, ErrNotFound
tags := normalizeMBTags(resp.Tags)
if scale != 1.0 {
for i := range tags {
tags[i].Weight *= scale
}
}
return out, nil
return tags, nil
}
// TestConnection issues a tiny search request; any non-transient response
+87 -1
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -50,13 +51,98 @@ func TestMusicBrainzFetch_GatedOff(t *testing.T) {
TrackRef{MBID: "abc"}); !errors.Is(err, ErrNotFound) {
t.Errorf("disabled: err = %v, want ErrNotFound", err)
}
// No MBID → ErrNotFound (MBID-only provider).
// No recording MBID and no artist MBID → nothing to look up → ErrNotFound.
if _, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"}); !errors.Is(err, ErrNotFound) {
t.Errorf("no MBID: err = %v, want ErrNotFound", err)
}
}
// mbEntityServer returns tags keyed by entity path segment ("recording" or
// "artist"), so a test can make the recording untagged while the artist has
// tags (or vice-versa). An empty body string means "{tags:[]}".
func mbEntityServer(recordingJSON, artistJSON string) *httptest.Server {
empty := `{"tags":[]}`
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := empty
switch {
case strings.Contains(r.URL.Path, "/recording/"):
if recordingJSON != "" {
body = recordingJSON
}
case strings.Contains(r.URL.Path, "/artist/"):
if artistJSON != "" {
body = artistJSON
}
}
_, _ = w.Write([]byte(body))
}))
}
func TestMusicBrainzFetch_ArtistFallbackWhenRecordingUntagged(t *testing.T) {
// Recording exists but has no tags; artist has tags → use the artist's,
// down-weighted by artistTagWeightFactor.
srv := mbEntityServer(``, `{"tags":[{"count":4,"name":"art rock"},{"count":2,"name":"experimental"}]}`)
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{MBID: "rec-1", ArtistMBID: "art-1"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
// "art rock" is the top artist tag (weight 1.0) × 0.6 fallback factor.
if got := m["art rock"]; got != 0.6 {
t.Errorf("art rock = %v, want 0.6 (down-weighted)", got)
}
if got := m["experimental"]; got != 0.3 { // 0.5 × 0.6
t.Errorf("experimental = %v, want 0.3", got)
}
}
func TestMusicBrainzFetch_ArtistOnlyWhenNoRecordingMBID(t *testing.T) {
srv := mbEntityServer(``, `{"tags":[{"count":1,"name":"folk"}]}`)
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B", ArtistMBID: "art-2"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if got := tagsByName(tags)["folk"]; got != 0.6 {
t.Errorf("folk = %v, want 0.6", got)
}
}
func TestMusicBrainzFetch_RecordingPreferredOverArtist(t *testing.T) {
// Recording has tags → they win at full weight; artist is never consulted.
srv := mbEntityServer(`{"tags":[{"count":3,"name":"shoegaze"}]}`,
`{"tags":[{"count":9,"name":"rock"}]}`)
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{MBID: "rec-2", ArtistMBID: "art-3"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
if got := m["shoegaze"]; got != 1.0 {
t.Errorf("shoegaze = %v, want 1.0 (recording, full weight)", got)
}
if _, ok := m["rock"]; ok {
t.Error("artist tag 'rock' leaked in — recording tags should win outright")
}
}
func TestMusicBrainzFetch_ParsesTags(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"tags":[{"count":2,"name":"post-punk"},{"count":4,"name":"melancholic"}]}`))
+118 -17
View File
@@ -16,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
)
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
@@ -46,6 +47,21 @@ type Config struct {
// contribution entirely (falls back to genre-only).
EnrichedTagScale float64
// EraScale weights the decade/era facet (#1530): each play adds
// base × this scale to its album's decade bucket ("1990s"), where base
// is the decayed engagement (or the tag-like bonus for a liked track).
// Era is a coarse signal, so it's kept ≤ 1 to imprint at most as strongly
// as the raw engagement; 0 disables the era facet entirely. Tunable in the
// lab (mirrors EnrichedTagScale, #1520).
EraScale float64
// MoodScale weights the mood facet (#1534): each play adds base × this
// scale to the canonical mood buckets (melancholic / chill / …) derived
// from its enriched folksonomy tags via internal/mood. Coverage is partial
// (depends on tag enrichment), so it's a supplement kept ≤ 1; 0 disables
// the mood facet entirely. Tunable in the lab (mirrors EraScale).
MoodScale float64
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
// go. Aggregation already protects an artist the user likes (one skip
// nets out against many good plays); the floor additionally bounds the
@@ -58,11 +74,17 @@ type Config struct {
WeightEpsilon float64
// Size caps (guard rails for pathological libraries). Persist the top
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags.
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags
// and eras. Real libraries span ~a dozen decades, so the era caps rarely
// bind — they exist for symmetry with the other facets.
MaxArtists int
MaxNegArtists int
MaxTags int
MaxNegTags int
MaxEras int
MaxNegEras int
MaxMoods int
MaxNegMoods int
}
// DefaultConfig returns the tuned starting configuration.
@@ -75,6 +97,8 @@ func DefaultConfig() Config {
TrackLikeBonus: 1.0,
TagLikeBonus: 0.5,
EnrichedTagScale: 0.5,
EraScale: 0.5,
MoodScale: 0.5,
ArtistFloor: -3.0,
TagFloor: -3.0,
WeightEpsilon: 0.05,
@@ -82,6 +106,10 @@ func DefaultConfig() Config {
MaxNegArtists: 150,
MaxTags: 200,
MaxNegTags: 60,
MaxEras: 30,
MaxNegEras: 15,
MaxMoods: 20,
MaxNegMoods: 10,
}
}
@@ -101,36 +129,42 @@ func BuildTasteProfile(
) error {
q := dbq.New(pool)
artistW, tagW, err := accumulate(ctx, q, userID, cfg)
artistW, tagW, eraW, moodW, err := accumulate(ctx, q, userID, cfg)
if err != nil {
return err
}
applyFloor(artistW, cfg.ArtistFloor)
applyFloor(tagW, cfg.TagFloor)
// Era + mood share the tag floor (all text-keyed facets; a decade or mood
// that's been heavily skipped shouldn't dominate as an extreme negative).
applyFloor(eraW, cfg.TagFloor)
applyFloor(moodW, cfg.TagFloor)
strLess := func(a, b string) bool { return a < b }
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags,
func(a, b string) bool { return a < b })
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, strLess)
moods := rankWeighted(moodW, cfg.WeightEpsilon, cfg.MaxMoods, cfg.MaxNegMoods, strLess)
if err := persist(ctx, pool, userID, artists, tags); err != nil {
if err := persist(ctx, pool, userID, artists, tags, eras, moods); err != nil {
return err
}
logger.Debug("taste: profile rebuilt",
"user_id", uuidString(userID), "artists", len(artists), "tags", len(tags))
logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
"artists", len(artists), "tags", len(tags), "eras", len(eras), "moods", len(moods))
return nil
}
// accumulate sums decayed play engagement and like bonuses into artist/tag
// weight maps.
// accumulate sums decayed play engagement and like bonuses into
// artist/tag/era/mood weight maps.
func accumulate(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
) (map[pgtype.UUID]float64, map[string]float64, error) {
) (map[pgtype.UUID]float64, map[string]float64, map[string]float64, map[string]float64, error) {
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
UserID: userID,
Column2: cfg.WindowDays,
})
if err != nil {
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
return nil, nil, nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
}
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
@@ -141,12 +175,14 @@ func accumulate(
Column2: int32(cfg.WindowDays),
})
if err != nil {
return nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
return nil, nil, nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
}
playedTags := groupTagsByTrack(playedTagRows)
artistW := make(map[pgtype.UUID]float64)
tagW := make(map[string]float64)
eraW := make(map[string]float64)
moodW := make(map[string]float64)
for _, p := range plays {
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
artistW[p.ArtistID] += e
@@ -154,15 +190,17 @@ func accumulate(
tagW[tag] += e
}
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
foldMoods(moodW, playedTags[p.TrackID], e, cfg.MoodScale)
}
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
if err != nil {
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
return nil, nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
}
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
if err != nil {
return nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
return nil, nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
}
likedTags := groupTagsByTrack(likedTagRows)
for _, lt := range likedTracks {
@@ -171,16 +209,18 @@ func accumulate(
tagW[tag] += cfg.TagLikeBonus
}
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
foldMoods(moodW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.MoodScale)
}
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
if err != nil {
return nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
return nil, nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
}
for _, aid := range likedArtists {
artistW[aid] += cfg.ArtistLikeBonus
}
return artistW, tagW, nil
return artistW, tagW, eraW, moodW, nil
}
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
@@ -206,10 +246,51 @@ func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale
}
}
// foldEra adds a track's decade bucket to eraW weighted by base × scale — base
// is the play's decayed engagement or the tag-like bonus, scale (EraScale)
// dials the facet's strength. scale=0 disables the era facet; undated tracks
// (decade "") contribute nothing so they don't pollute the profile.
func foldEra(eraW map[string]float64, d pgtype.Date, base, scale float64) {
if scale == 0 {
return
}
if decade := decadeOf(d); decade != "" {
eraW[decade] += base * scale
}
}
// foldMoods adds a track's canonical mood buckets (derived from its enriched
// folksonomy tags via internal/mood) to moodW weighted by base × scale. base is
// the play's decayed engagement or the tag-like bonus; scale (MoodScale) dials
// the facet's strength. scale=0 disables the mood facet; tracks with no
// mood-word tags contribute nothing.
func foldMoods(moodW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
if scale == 0 {
return
}
names := make([]string, len(tags))
for i, t := range tags {
names[i] = t.Tag
}
for _, m := range mood.Of(names) {
moodW[m] += base * scale
}
}
// decadeOf maps an album release date to a decade bucket like "1990s", or ""
// for an absent/invalid date. Mirrored by the recommendation scorer so a
// candidate's era is derived the same way it was learned.
func decadeOf(d pgtype.Date) string {
if !d.Valid {
return ""
}
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
}
// persist atomic-replaces the user's profile rows inside one transaction.
func persist(
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
artists []weighted[pgtype.UUID], tags []weighted[string],
artists []weighted[pgtype.UUID], tags, eras, moods []weighted[string],
) error {
tx, err := pool.Begin(ctx)
if err != nil {
@@ -238,6 +319,26 @@ func persist(
return fmt.Errorf("taste: insert tag: %w", err)
}
}
if err := qtx.DeleteTasteProfileErasForUser(ctx, userID); err != nil {
return fmt.Errorf("taste: delete eras: %w", err)
}
for _, e := range eras {
if err := qtx.InsertTasteProfileEra(ctx, dbq.InsertTasteProfileEraParams{
UserID: userID, Era: e.Key, Weight: e.Weight,
}); err != nil {
return fmt.Errorf("taste: insert era: %w", err)
}
}
if err := qtx.DeleteTasteProfileMoodsForUser(ctx, userID); err != nil {
return fmt.Errorf("taste: delete moods: %w", err)
}
for _, m := range moods {
if err := qtx.InsertTasteProfileMood(ctx, dbq.InsertTasteProfileMoodParams{
UserID: userID, Mood: m.Key, Weight: m.Weight,
}); err != nil {
return fmt.Errorf("taste: insert mood: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("taste: commit: %w", err)
}
+61
View File
@@ -0,0 +1,61 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommits"
],
"baseBranches": ["dev"],
"timezone": "America/New_York",
"schedule": ["every weekend"],
"prHourlyLimit": 2,
"prConcurrentLimit": 8,
"ignorePaths": [
"**/node_modules/**",
"**/vendor/**",
"flutter_client/**"
],
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 5am on the first day of the month"]
},
"packageRules": [
{
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
},
{
"description": "Hold all major bumps for manual approval via the dependency dashboard",
"matchUpdateTypes": ["major"],
"automerge": false,
"dependencyDashboardApproval": true,
"addLabels": ["deps", "deps:major"]
},
{
"description": "Group Go module updates into one PR",
"matchManagers": ["gomod"],
"groupName": "go modules"
},
{
"description": "Group CI workflow action bumps",
"matchManagers": ["github-actions"],
"groupName": "ci actions"
},
{
"description": "Group Docker base-image bumps (Dockerfile + compose)",
"matchManagers": ["dockerfile", "docker-compose"],
"groupName": "docker images"
},
{
"description": "Group the Android Gradle/Kotlin toolchain",
"matchManagers": ["gradle", "gradle-wrapper"],
"groupName": "android gradle"
},
{
"description": "Group web npm non-major bumps",
"matchManagers": ["npm"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "web npm (non-major)"
}
]
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getTagProviders,
updateTagProvider,
testTagProvider,
type TagProvider,
type TagProvidersResponse
} from './admin';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
}));
import { api } from './client';
describe('admin tag-sources API', () => {
beforeEach(() => vi.clearAllMocks());
it('getTagProviders GETs the correct path', async () => {
const sample: TagProvidersResponse = {
providers: [
{
id: 'musicbrainz',
display_name: 'MusicBrainz',
requires_api_key: false,
supports: ['track_tags'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
}
],
sources_version: 1
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await getTagProviders();
expect(api.get).toHaveBeenCalledWith('/api/admin/tag-sources');
expect(got.providers[0].id).toBe('musicbrainz');
expect(got.sources_version).toBe(1);
});
it('updateTagProvider PATCHes the right path with patch body', async () => {
const patched: TagProvider & { version_bumped: boolean } = {
id: 'lastfm',
display_name: 'Last.fm',
requires_api_key: true,
supports: ['track_tags'],
enabled: true,
api_key_set: true,
display_order: 1,
testable: true,
version_bumped: true
};
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
const got = await updateTagProvider('lastfm', { enabled: true, api_key: 'mykey' });
expect(api.patch).toHaveBeenCalledWith(
'/api/admin/tag-sources/lastfm',
{ enabled: true, api_key: 'mykey' }
);
expect(got.api_key_set).toBe(true);
expect(got.version_bumped).toBe(true);
});
it('testTagProvider POSTs to /test endpoint with empty body', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
duration_ms: 55
});
const got = await testTagProvider('musicbrainz');
expect(api.post).toHaveBeenCalledWith('/api/admin/tag-sources/musicbrainz/test', {});
expect(got.ok).toBe(true);
expect(got.duration_ms).toBe(55);
});
it('testTagProvider returns ok=false with error string on failure', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'lastfm api key not set'
});
const got = await testTagProvider('lastfm');
expect(got.ok).toBe(false);
expect(got.error).toBe('lastfm api key not set');
});
});
+61
View File
@@ -383,6 +383,67 @@ export function createCoverProvidersQuery() {
});
}
// Tag-enrichment providers -------------------------------------------------
// Parallel to the cover-art providers surface, over /api/admin/tag-sources.
// Independent settings so a new folksonomy source is added without touching
// art config (#1490).
export type TagProviderCapability = 'track_tags';
export type TagProvider = {
id: string;
display_name: string;
requires_api_key: boolean;
supports: TagProviderCapability[];
enabled: boolean;
api_key_set: boolean;
display_order: number;
testable: boolean;
};
export type TagProvidersResponse = {
providers: TagProvider[];
sources_version: number;
};
export async function getTagProviders(): Promise<TagProvidersResponse> {
return api.get<TagProvidersResponse>('/api/admin/tag-sources');
}
export type UpdateTagProviderPatch = {
enabled?: boolean;
api_key?: string;
};
export type UpdateTagProviderResponse = TagProvider & {
version_bumped: boolean;
};
export async function updateTagProvider(
id: string,
patch: UpdateTagProviderPatch
): Promise<UpdateTagProviderResponse> {
return api.patch<UpdateTagProviderResponse>(`/api/admin/tag-sources/${id}`, patch);
}
export type TestTagProviderResponse = {
ok: boolean;
duration_ms?: number;
error?: string;
};
export async function testTagProvider(id: string): Promise<TestTagProviderResponse> {
return api.post<TestTagProviderResponse>(`/api/admin/tag-sources/${id}/test`, {});
}
export function createTagProvidersQuery() {
return createQuery({
queryKey: qk.tagProviders(),
queryFn: getTagProviders,
staleTime: 60_000
});
}
// Admin user-management ------------------------------------------------------
export type AdminUser = {
+1
View File
@@ -48,6 +48,7 @@ export const qk = {
scanStatus: () => ['scanStatus'] as const,
coverage: () => ['coverage'] as const,
coverProviders: () => ['coverProviders'] as const,
tagProviders: () => ['tagProviders'] as const,
adminUsers: () => ['adminUsers'] as const,
adminInvites: () => ['adminInvites'] as const,
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
+4
View File
@@ -13,6 +13,7 @@ export type WeightProfile = {
context_weight: number;
similarity_weight: number;
taste_weight: number;
context_time_weight: number;
};
export type TasteTuning = {
@@ -20,6 +21,9 @@ export type TasteTuning = {
engagement_hard_skip: number;
engagement_neutral: number;
engagement_full: number;
enriched_tag_scale: number;
era_scale: number;
mood_scale: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
+5 -1
View File
@@ -35,5 +35,9 @@
transition-transform duration-200
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
>
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
<QueueList
onClose={() => closeQueueDrawer()}
active={player.queueDrawerOpen}
bind:closeButtonRef={closeButton}
/>
</aside>
+15 -3
View File
@@ -1,9 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import QueueDrawer from './QueueDrawer.svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';
// QueueTrackRow (rendered inside QueueDrawer → QueueList) now renders a
// LikeButton, which reads createLikedIdsQuery. Stub the likes API so the
// rows don't need a real QueryClient in context. Both mocks — and the
// emptyLikesMock import — must precede the QueueDrawer import below, since
// loading the component evaluates the mocked modules and the hoisted
// factories need their referenced bindings already initialized.
vi.mock('$lib/api/likes', () => emptyLikesMock());
const closeQueueDrawer = vi.fn();
let queueValue: TrackRef[] = [];
let indexValue = 0;
@@ -17,12 +25,16 @@ vi.mock('$lib/player/store.svelte', () => ({
get queueDrawerOpen() { return openValue; }
},
// QueueTrackRow imports these from the store; provide stubs so its
// module-load doesn't break when QueueDrawer renders rows.
// module-load doesn't break when QueueDrawer renders rows. QueueList
// imports clearQueue for its header action.
playFromQueueIndex: vi.fn(),
removeFromQueue: vi.fn(),
moveQueueItem: vi.fn()
moveQueueItem: vi.fn(),
clearQueue: vi.fn()
}));
import QueueDrawer from './QueueDrawer.svelte';
const t = (id: string): TrackRef => makeTrack({ id, title: `Song ${id}` });
describe('QueueDrawer', () => {
+97 -16
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import { player } from '$lib/player/store.svelte';
import { untrack } from 'svelte';
import { X, Trash2, ArrowDown } from 'lucide-svelte';
import { player, clearQueue } from '$lib/player/store.svelte';
import QueueTrackRow from './QueueTrackRow.svelte';
// onClose: when provided, renders an X button in the header so the
@@ -8,12 +9,66 @@
// now-playing route (visible at lg+ widths) omits it.
// closeButtonRef: bind:this hook so the drawer can focus the X for
// keyboard users on open.
// active: true when the queue is on-screen (drawer open, or the always-
// visible now-playing panel). Gates the scroll-to-current behavior.
type Props = {
onClose?: () => void;
closeButtonRef?: HTMLButtonElement;
active?: boolean;
};
let { onClose, closeButtonRef = $bindable() }: Props = $props();
let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
let scrollBody: HTMLElement | undefined = $state();
// Whether the now-playing row is (at least partly) within the scroll
// viewport. Drives auto-follow (only follow while the user is watching the
// current track) and the "Jump to current" pill (shown when it's off-screen).
let currentInView = $state(true);
let sawFirstIndex = false;
function scrollToCurrent(block: ScrollLogicalPosition, behavior: ScrollBehavior = 'auto') {
(scrollBody?.children[player.index] as HTMLElement | undefined)?.scrollIntoView({
block,
behavior,
});
currentInView = true;
}
function recomputeInView() {
const row = scrollBody?.children[player.index] as HTMLElement | undefined;
if (!scrollBody || !row) {
currentInView = true;
return;
}
const b = scrollBody.getBoundingClientRect();
const r = row.getBoundingClientRect();
currentInView = r.bottom > b.top && r.top < b.bottom;
}
// On open (active flips true, or on mount for the always-visible panel),
// center the now-playing row — parity with the Android queue.
$effect(() => {
if (!active) return;
if (untrack(() => player.queue.length) === 0) return;
requestAnimationFrame(() => scrollToCurrent('center'));
});
// Follow the current track as it auto-advances, but only while the user is
// still watching it — if they've scrolled away, leave them there (the pill
// offers the way back). block:'nearest' keeps it minimal (no yank when the
// row is already visible). Index is tracked; currentInView is read untracked
// so a scroll that hides the row doesn't itself re-trigger a scroll.
$effect(() => {
player.index; // subscribe: follow on advance
if (!sawFirstIndex) {
sawFirstIndex = true;
return; // the open effect already handled the initial position
}
if (!active) return;
if (untrack(() => player.queue.length) === 0) return;
if (!untrack(() => currentInView)) return;
requestAnimationFrame(() => scrollToCurrent('nearest'));
});
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
@@ -23,7 +78,7 @@
}
</script>
<div class="flex h-full flex-col">
<div class="relative flex h-full flex-col">
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<h2 class="text-lg font-semibold">Queue</h2>
@@ -32,20 +87,33 @@
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
</p>
</div>
{#if onClose}
<button
type="button"
bind:this={closeButtonRef}
aria-label="Close queue"
onclick={onClose}
class="text-text-secondary hover:text-text-primary"
>
<X size={20} />
</button>
{/if}
<div class="flex items-center gap-1">
{#if player.queue.length > 0}
<button
type="button"
aria-label="Clear queue"
title="Clear queue"
onclick={() => clearQueue()}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>
<Trash2 size={18} />
</button>
{/if}
{#if onClose}
<button
type="button"
bind:this={closeButtonRef}
aria-label="Close queue"
onclick={onClose}
class="rounded p-1 text-text-secondary hover:text-text-primary"
>
<X size={20} />
</button>
{/if}
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
{#if player.queue.length === 0}
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
{:else}
@@ -54,4 +122,17 @@
{/each}
{/if}
</div>
{#if active && player.queue.length > 0 && !currentInView}
<button
type="button"
onclick={() => scrollToCurrent('center', 'smooth')}
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1.5
rounded-full bg-action-secondary px-3 py-1.5 text-xs font-medium
text-action-fg shadow-lg"
>
<ArrowDown size={14} />
Jump to current
</button>
{/if}
</div>
@@ -3,7 +3,9 @@
import { draggable, type DragEventData } from '@neodrag/svelte';
import type { TrackRef } from '$lib/api/types';
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
import { offsetToDelta } from './queue-row-math';
import LikeButton from './LikeButton.svelte';
let { track, index, isCurrent } = $props<{
track: TrackRef;
@@ -65,6 +67,13 @@
<GripVertical size={16} />
</button>
<img
src={coverUrl(track.album_id)}
alt=""
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
class="h-10 w-10 flex-shrink-0 rounded object-cover"
/>
<button
type="button"
onclick={handleBodyClick}
@@ -80,6 +89,8 @@
<div class="text-xs text-text-secondary truncate">{track.artist_name}</div>
</button>
<LikeButton entityType="track" entityId={track.id} />
<button
type="button"
onclick={handleRemove}
+10 -1
View File
@@ -1,8 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import QueueTrackRow from './QueueTrackRow.svelte';
import { emptyLikesMock } from '../../test-utils/mocks/likes';
import { makeTrack } from '$test-utils/fixtures/track';
// QueueTrackRow now renders a LikeButton, which reads createLikedIdsQuery.
// Stub the likes API so the row doesn't need a real QueryClient in context.
// The mock (and its emptyLikesMock import) must precede the component import
// below: importing QueueTrackRow transitively loads LikeButton → the mocked
// module, and the hoisted factory needs emptyLikesMock already initialized.
vi.mock('$lib/api/likes', () => emptyLikesMock());
import QueueTrackRow from './QueueTrackRow.svelte';
const playFromQueueIndex = vi.fn();
const removeFromQueue = vi.fn();
const moveQueueItem = vi.fn();
+4 -1
View File
@@ -124,7 +124,10 @@ export function useEventsDispatcher(): void {
// #415: tag the play with the system playlist it came from
// (null for library / user-playlist / radio) so the server
// can advance that playlist's rotation.
source: player.queueSource ?? undefined
source: player.queueSource ?? undefined,
// #1551: device class for context conditioning. The web app is a
// browser session, so it reports 'web'.
device_class: 'web'
});
// The user may have moved on by the time the response arrives. Only
// adopt the id if we're still on the same track and still playing.
+15
View File
@@ -505,6 +505,21 @@ export function removeFromQueue(idx: number): void {
_error = null;
}
// Clear the whole queue and stop playback — mirrors removeFromQueue's
// empty-queue branch. Also drops the radio/system source + self-heal closure
// so the emptied player doesn't try to refill from a now-irrelevant source.
export function clearQueue(): void {
_queue = [];
_index = 0;
_state = 'idle';
_position = 0;
_duration = 0;
_error = null;
_radioSeedId = null;
_queueSource = null;
_queueRefetch = null;
}
export function playFromQueueIndex(idx: number): void {
if (idx < 0 || idx >= _queue.length) return;
_radioSeedId = null;
+7 -2
View File
@@ -168,9 +168,14 @@
style="display: none"
></audio>
<QueueDrawer />
<QueryClientProvider client={queryClient}>
<!-- QueueDrawer must be inside the provider: its rows render LikeButton,
which calls useQueryClient() at init. The drawer's <aside> is always
mounted, so the moment the queue is populated (on first play) those
LikeButtons instantiate — outside the provider they throw
"No QueryClient was found" and abort the play flush. -->
<QueueDrawer />
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
<Shell>{@render children()}</Shell>
{:else}
@@ -13,10 +13,14 @@
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider,
updateSMTPConfig,
testSMTPConfig,
createSMTPConfigQuery,
type CoverProvider,
type TagProvider,
type SMTPConfig
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
@@ -280,6 +284,62 @@
}
}
// ---- Tag enrichment providers ----
// Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490).
const tagProvidersStore = $derived(createTagProvidersQuery());
const tagProvidersQ = $derived($tagProvidersStore);
let tagLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
let tagTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
let tagSaving = $state<Record<string, boolean>>({});
let tagTesting = $state<Record<string, boolean>>({});
$effect(() => {
if (tagProvidersQ.data) {
for (const p of tagProvidersQ.data.providers) {
if (!tagLocalState[p.id]) {
tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
}
}
}
});
function tagHasChanges(provider: TagProvider): boolean {
const local = tagLocalState[provider.id];
if (!local) return false;
return provider.enabled !== local.enabled || local.apiKey !== '';
}
async function tagSave(provider: TagProvider) {
const local = tagLocalState[provider.id];
if (!local || !tagHasChanges(provider)) return;
const patch: { enabled?: boolean; api_key?: string } = {};
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
if (local.apiKey !== '') patch.api_key = local.apiKey;
tagSaving[provider.id] = true;
try {
const result = await updateTagProvider(provider.id, patch);
tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
await client.invalidateQueries({ queryKey: qk.tagProviders() });
} catch (_e) {
// Inline error surfacing handled by the mutation toast layer; swallow here.
} finally {
tagSaving[provider.id] = false;
}
}
async function tagTest(provider: TagProvider) {
tagTesting[provider.id] = true;
try {
tagTestResults[provider.id] = await testTagProvider(provider.id);
} catch (e) {
tagTestResults[provider.id] = { ok: false, error: (e as Error).message };
} finally {
tagTesting[provider.id] = false;
}
}
// SMTP config -------------------------------------------------------------
const smtpStore = $derived(createSMTPConfigQuery());
@@ -597,6 +657,108 @@
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- Tag enrichment providers -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Tag enrichment sources</h3>
<p class="mt-1 text-sm text-text-secondary">
Folksonomy style/mood tags fetched per track and folded into each
listener's taste profile alongside the raw file genre — so "Rock"
gains "post-punk / shoegaze / melancholic". All enabled sources are
merged. Enabling or disabling a source re-opens previously-processed
tracks for a fresh pass.
</p>
</div>
{#if tagProvidersQ.data}
{#each tagProvidersQ.data.providers as provider (provider.id)}
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
<header class="flex items-baseline justify-between gap-3">
<div class="flex items-center gap-2">
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
<span
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
title={provider.enabled ? 'Active' : 'Disabled'}
></span>
</div>
<div class="flex flex-wrap gap-1">
{#each provider.supports as cap}
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
{/each}
</div>
</header>
{#if tagLocalState[provider.id]}
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="tag-enabled-{provider.id}">Enabled</label>
<div>
<input id="tag-enabled-{provider.id}" type="checkbox"
bind:checked={tagLocalState[provider.id].enabled} />
</div>
{#if provider.requires_api_key}
<label class="text-sm text-text-secondary" for="tag-key-{provider.id}">API key</label>
<div class="flex items-center gap-2">
<input id="tag-key-{provider.id}" type="password"
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Paste your API key to enable this source'}
bind:value={tagLocalState[provider.id].apiKey}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent flex-1" />
{#if provider.api_key_set}
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2 pt-1">
<button type="button"
disabled={!tagHasChanges(provider) || tagSaving[provider.id]}
onclick={() => tagSave(provider)}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{tagSaving[provider.id] ? 'Saving…' : 'Save changes'}
</button>
{#if provider.testable}
<button type="button"
disabled={tagTesting[provider.id]}
onclick={() => tagTest(provider)}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{tagTesting[provider.id] ? 'Testing…' : 'Test connection'}
</button>
{/if}
{#if tagTestResults[provider.id]}
{#if tagTestResults[provider.id].ok}
<p class="text-sm text-action-primary">
OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}
</p>
{:else}
<p class="text-sm text-error">
Failed — {tagTestResults[provider.id].error}
</p>
{/if}
{/if}
</div>
{/if}
{#if provider.id === 'musicbrainz'}
<p class="text-xs text-text-muted">
Keyless and on by default — the always-available baseline. Matches tags by
recording MBID, so coverage tracks how well your library is MusicBrainz-tagged.
</p>
{:else if provider.id === 'lastfm'}
<p class="text-xs text-text-muted">
Name-based (artist + title), so it covers tracks without an MBID. Requires a free
API key — get one at <a class="underline" href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer">last.fm/api</a>.
</p>
{/if}
</article>
{/each}
{:else if tagProvidersQ.isPending}
<p class="text-sm text-text-muted">Loading…</p>
{:else}
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- SMTP / email config -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '$lib/api/types';
import type { CoverProvider, SMTPConfig } from '$lib/api/admin';
import type { CoverProvider, TagProvider, SMTPConfig } from '$lib/api/admin';
// useQueryClient is wrapped so the page can call invalidateQueries() without
// a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default
@@ -23,6 +23,9 @@ vi.mock('$lib/api/admin', () => ({
createCoverProvidersQuery: vi.fn(),
updateCoverProvider: vi.fn(),
testCoverProvider: vi.fn(),
createTagProvidersQuery: vi.fn(),
updateTagProvider: vi.fn(),
testTagProvider: vi.fn(),
createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConfig: vi.fn()
@@ -39,6 +42,9 @@ import {
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider,
createSMTPConfigQuery,
updateSMTPConfig,
testSMTPConfig
@@ -95,6 +101,34 @@ const defaultCoverProviders = {
sources_version: 1
};
// Fixture tag providers
const providerMusicbrainz: TagProvider = {
id: 'musicbrainz',
display_name: 'MusicBrainz',
requires_api_key: false,
supports: ['track_tags'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
};
const providerLastfm: TagProvider = {
id: 'lastfm',
display_name: 'Last.fm',
requires_api_key: true,
supports: ['track_tags'],
enabled: false,
api_key_set: false,
display_order: 1,
testable: true
};
const defaultTagProviders = {
providers: [providerMusicbrainz, providerLastfm],
sources_version: 1
};
const testResponseOk = {
ok: true as const,
version: '2.0.5',
@@ -133,7 +167,7 @@ afterEach(() => {
invalidateQueries.mockReset();
});
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) {
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) {
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.config ?? cfgConnected })
);
@@ -160,6 +194,10 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
);
const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders);
(createTagProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: tpData, isPending: opts.tagProviders === null })
);
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.smtp ?? smtpUnset })
);
@@ -195,6 +233,16 @@ function providerCard(providerName: string): HTMLElement {
.closest('article') as HTMLElement;
}
function tagProvidersSection(): HTMLElement {
return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement;
}
function tagProviderCard(providerName: string): HTMLElement {
return within(tagProvidersSection())
.getByRole('heading', { name: providerName, level: 4 })
.closest('article') as HTMLElement;
}
describe('/admin/integrations', () => {
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
setup();
@@ -641,3 +689,48 @@ describe('Lidarr first-time setup (Test-then-Save)', () => {
});
});
});
describe('/admin/integrations tag sources', () => {
test('renders tag enrichment providers', () => {
setup();
const section = tagProvidersSection();
expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument();
expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument();
expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1);
});
test('enabling Last.fm and typing a key calls updateTagProvider', async () => {
setup();
(updateTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerLastfm,
enabled: true,
api_key_set: true,
version_bumped: true
});
const card = tagProviderCard('Last.fm');
const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement;
await fireEvent.click(enabledCheckbox);
const keyInput = within(card).getByLabelText(/api key/i);
await fireEvent.input(keyInput, { target: { value: 'lfmkey' } });
await fireEvent.click(within(card).getByRole('button', { name: /save changes/i }));
expect(updateTagProvider).toHaveBeenCalledWith(
'lastfm',
expect.objectContaining({ enabled: true, api_key: 'lfmkey' })
);
await waitFor(() =>
expect(invalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['tagProviders'] })
)
);
});
test('Test connection on a tag provider calls testTagProvider and shows OK', async () => {
setup();
(testTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
const card = tagProviderCard('MusicBrainz');
await fireEvent.click(within(card).getByRole('button', { name: /test connection/i }));
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument());
});
});
+6 -2
View File
@@ -29,14 +29,18 @@
{ key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' },
{ key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' },
{ key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' },
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' }
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' },
{ key: 'context_time_weight', label: 'Time-of-day weight', hint: "Artist's time-of-day/weekday affinity for the current context, in [-1, +1]. 0 = ignore when you listen." }
];
const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [
{ key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." },
{ key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads 1.' },
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' }
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' },
{ key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'How much folksonomy tags (MusicBrainz/Last.fm) count vs raw file genre, in [0, 1]. 0 = genre only.' },
{ key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' },
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' }
];
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
@@ -28,6 +28,7 @@ const weights = (over: Partial<Record<string, number>> = {}) => ({
context_weight: 0.5,
similarity_weight: 1.5,
taste_weight: 1.5,
context_time_weight: 1,
...over
});
@@ -36,6 +37,9 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
engagement_hard_skip: 0.05,
engagement_neutral: 0.3,
engagement_full: 0.9,
enriched_tag_scale: 0.5,
era_scale: 0.5,
mood_scale: 0.5,
...over
});
+8
View File
@@ -37,6 +37,14 @@ if (typeof window !== 'undefined') {
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
}
// jsdom doesn't implement Element.prototype.scrollIntoView. Components that
// call it (queue auto-scroll to the now-playing row, the alphabetical rail)
// would throw an unhandled TypeError in tests — which fails the run even when
// every assertion passes. No-op it; tests never assert on scroll position.
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
// W-T3 moved toast rendering out of per-page markup into a single
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
// without the layout, so we mount ToastHost here so `pushToast()` calls