• v2026.08.03 — Discover requests: taste-aware, rotating, snoozable, tag-targeted
    test-web / test (push) Successful in 1m5s
    test-go / test (push) Successful in 1m30s
    android / Build + lint + test (push) Successful in 5m1s
    test-go / integration (push) Successful in 5m29s
    release / Build signed APK (tag releases only) (push) Successful in 4m21s
    release / Build + push container image (push) Successful in 17s
    Stable

    bvandeusen released this 2026-08-03 08:46:50 -04:00 | 346 commits to main since this release

    Per-day CalVer. Milestone #268 — the Discover request surface, rebuilt. Server + web + Android, with schema migrations. Merged via PR #116; main CI green on 324059b2 (Go, web, Android incl. debug assemble, and the image build).

    The Discover request surface was the last recommendation surface still running its early-May M5c implementation. Reported symptoms: "shows the same artists until you 'request' one" and "once it has a strong signal of your taste seems to go stale." Both were real, with separate causes — and the second got worse the more you listened, by construction.

    Why it went stale

    Four mechanisms, all fixed:

    1. No rotation. A deterministic top-N over a score ordering shows the same faces every day until one is requested away — the tail of the ranking was simply unreachable.
    2. Unbounded signal. signal = 5×liked + Σexp(-age/halflife) grew without limit, so a few heavily-played artists monopolised every slot and entrenched harder as listening accumulated.
    3. Skips counted as engagement. No was_skipped filter, so skipping an artist repeatedly increased its signal and pushed more of its neighbours at you.
    4. No diversity cap. All twelve slots could be neighbours of one artist.

    Seeded from your taste profile, not raw play counts

    Seeds now come from taste_profile_artists.weight — already decayed, damped and signed, so an artist you've drifted away from stops contributing instead of accumulating forever, and can contribute negatively. Log-damped so one heavy artist can't monopolise. Skips no longer read as affinity.

    While the profile is still empty (new account, or before the first nightly recompute) it falls back to likes + non-skipped plays, so the surface never goes blank.

    The deck rotates daily

    Top scorers always lead; the remaining slots rotate on md5(mbid || date) — stable within a day so pull-to-refresh doesn't reshuffle under you, different tomorrow, and it needs no stored state. A per-seed cap stops one artist owning the deck, with a floor: diversity is a preference with score-order top-up, never a quota that leaves you with a three-card surface.

    "Not right now" — a snooze, not a dislike

    You can park a suggestion for ~90 days. It expires on its own, records no verdict on the music, and never touches your taste profile — this is acquisition triage, not negative feedback. That's enforced structurally, not by convention: the filter sits at the candidate stage rather than in the score, because a scoring term is exactly where it would leak.

    Both clients flip the card in place with an Undo rather than yanking it out from under your finger, and keep a "Not right now" list below the deck with a return estimate — that list is the only route back once a card has left the deck, so it isn't optional garnish. On Android the write goes through the offline queue, modelled so a snooze queued while offline can't replay after you've undone it.

    It can now tell you why

    Cards say "Matches your taste in shoegaze and dream pop." when the artist's own tags overlap your taste profile — describing the music rather than the graph ("adjacent to something you played").

    This needed a tag cache for artists you don't own (track_tags can't serve it — it's keyed to tracks you have). A background worker fills it strongest-candidates-first, because the candidate pool is far larger than MusicBrainz's ~1 req/s can ever fully cover. Expect this to be sparse at first and to fill in over days. Coverage will always be partial — obscure artists stay thin — so an untagged candidate is scored exactly as before rather than pushed down.

    New: Admin → Tuning → "Discover requests"

    Two DB-backed knobs, no restart:

    • Taste-tag weight — how strongly a tag match boosts a candidate. score × (1 + w × overlap). Setting it to 0 turns the tag term off entirely and returns the deck to similarity-only ranking.
    • Snooze length — how long "not right now" parks something.

    Its own scope rather than part of the taste card, deliberately: snooze length filed under "taste" would sit one careless join away from the leak described above.

    Also fixed

    • Generated database code was never verified against its SQL sources (#2380). 307 queries, ~12k lines of committed codegen, and nothing checked it still matched. CI now verifies it before anything else runs — and it has already caught two real drifts that the integration tests passed straight through. Passing tests against a real database proves the SQL is valid, not that the committed Go matches it.
    • That check was blind to newly added files (#2382) — git diff ignores untracked paths, so a commit that added a query and forgot its generated file would have passed. This release contains the first commit that would have tripped it.
    • "in about a month" was unreachable in both clients (#2392) — a threshold sitting above its own divisor made the branch dead code. Found by the unit test written for it.

    Install

    1. Redeploy the server image — this release carries server changes and migrations 0049, 0050, 0051, which apply automatically on restart. 0051 also widens the tuning-audit scope constraint (same migration, per policy).
    2. Install the attached signed APK — the Android client carries the snooze affordance and the tag attribution.

    Web needs nothing beyond the server redeploy.

    Verify

    CI covered all three lanes on every commit. These are the parts it cannot sign off:

    • Come back tomorrow. Does the deck actually change without you requesting anything? That's symptom one.
    • Over the next weeks, does it stop narrowing as your listening concentrates? That's symptom two, and it's the one that used to get worse with use.
    • Snooze a suggestion. The card should flip in place with an Undo, not vanish under your finger — then appear in "Not right now" below with a return estimate. Undo from either place.
    • Tag attribution. Watch for "Matches your taste in …" appearing on more cards over the coming days as the enricher works through the backlog. Sparse at first is expected, not a fault.
    • Admin → Tuning → Discover requests. Set taste-tag weight to 0, refresh Discover: the ordering should visibly revert to similarity-only. Reset to defaults to restore.

    Known and deliberate

    Tag coverage for out-of-library artists is permanently partial — it depends on the artist having an MBID and on MusicBrainz/Last.fm actually having tags for them. The ranking is built so that costs an untagged candidate nothing rather than penalising it, and the admin card's copy says so. Coverage counts are queryable server-side if you want to see the shape of it.

    🤖 Generated with Claude Code

    Downloads
  • v2026.08.01 — Home veil: change-triggered, settle-driven, with refresh feedback
    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
    Stable

    bvandeusen released this 2026-07-31 23:38:11 -04:00 | 364 commits to main since this release

    Per-day CalVer. Android-only — no server or web changes, no schema migrations. main CI green on 0cea8298 (lint + detekt + unit tests + signed-debug assemble, plus the image build). Merged via PR #114; closes issue #2327.

    The "Updating your mixes…" veil from v2026.07.14 was reported as "swipes on and off before the update completes in most cases", with "a number of other times where the contents will visibly update without the veil triggering." This is that, fixed at the root rather than re-tuned.

    Most of the churn no longer happens at all

    The veil was papering over a data-layer bug. replaceSection was delete-then-insert, per section and un-transacted, so the observing Flow genuinely emitted emptyList() — a real, visible collapse — before the new ids landed, and did it seven times in sequence. Even unchanged tiles flickered.

    • One @Transaction swap across all seven sections. Room notifies observers only on commit, so the empty gap is never observed and Home swaps in a single emission. (CachedQuarantineDao already carried this exact fix for this exact reason; Home never got it.)
    • distinctUntilChanged on the id list ahead of flatMapLatest, so a section whose contents didn't move no longer tears down and rebuilds every tile's hydration flow.

    The veil is now driven by the screen, not a stopwatch

    Three separate reasons it used to lower early: the old refresh().join() + 500ms measured the network pull, which finishes long before the visible work (ids land → tiles hydrate → artwork loads); a plain isUpdating Boolean cleared in a finally got cleared by whichever of two overlapping refreshes finished first (reconnect and the 03:00 rebuild routinely arrive together); and failures were swallowed, so a failed pull reported success with no retry.

    It now raises, works (retrying behind the veil), then holds until the content has been still for a quiet window and nothing is still loading — with a floor so it can't flash and a hard ceiling so it can never strand you. The ceiling caps the veil, never the refresh.

    It only appears when something actually changed

    A refresh that returns what's already cached raises nothing, so a launch over a warm cache is immediate — no veil hiding an update that isn't happening.

    Artwork no longer pops in after the veil lifts

    Art was the most visible offender and the refresh coroutine can't see it, so the composition reports upward: ServerImage — the single choke point behind every album, artist and playlist cover — counts its in-flight loads into a tracker the veil waits on. Art also crossfades app-wide now, with the placeholder fading out over the same window, which softens the pop everywhere the veil isn't involved.

    And it tells you when nothing changed

    Because an unchanged refresh raises no veil, a pull that found nothing new would otherwise give no response at all. A pull now always produces exactly one piece of feedback: the veil if something changed, "Already up to date" if not, "Couldn't check for updates" if it couldn't reach the server. Only for refreshes you asked for — background checks stay silent rather than toasting on every launch.

    Also veiled now: library-scan completion (Home never reacted to it before), playlist create/update/delete/tracks-changed, manual pull, and the initial load over a warm cache.

    Install

    Install the attached signed APK. Redeploying the server image is optional — it carries no changes in this release; it only matters for offering the new APK to other devices via in-app update.

    Verify on device

    This release is almost entirely about feel, which is the part CI cannot sign off:

    • Open the app on a warm cache → content appears immediately. No veil, no sections collapsing and refilling.
    • Pull to refresh with nothing new → "Already up to date".
    • Pull to refresh after the 03:00 rebuild → the veil wipes on and holds until tiles and their artwork have landed, then wipes off. Nothing should pop in after it lifts.
    • Pull with the server unreachable → "Couldn't check for updates" after it retries; your cached content stays put.
    • First run / cleared cache → skeleton as before, no veil over an empty screen.

    Known and deliberate

    The background freshness sweeper still re-fetches stale metadata, so a single tile can change unannounced. Left unveiled on purpose — raising a full-screen panel for one tile would be worse than the pop, and the new crossfade softens it. Worth revisiting only if it's noticeable in use.

    🤖 Generated with Claude Code

    Downloads
  • v2026.07.23 — Queue fix + cross-client queue enhancements
    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
    Stable

    bvandeusen released this 2026-07-23 08:38:10 -04:00 | 372 commits to main since this release

    Per-day CalVer. A web playback-blocking fix plus a batch of cross-client queue ("up next") improvements. main CI green on a26ef4e9 (all three lanes, incl. the signed debug APK assemble).

    Fix — web: clicking play did nothing (#1928)

    QueueDrawer was rendered outside QueryClientProvider. Its always-mounted <aside> renders a row per queued track, and the queue like-heart (added in #1596) calls useQueryClient() at init — so the instant the queue populated on first play, it threw "No QueryClient was found in Svelte context" and aborted the reactive flush that starts playback. The play button appeared dead and the console logged the error. Moved the drawer inside the provider.

    CI didn't catch it because QueueDrawer.test.ts mocks the likes API, which hid the real provider dependency.

    Queue enhancements (web + Android)

    • Opens on the now-playing track — the queue no longer starts at the top when the current song is further down (#1929 Android, #1931 web).
    • Auto-follow + "Jump to current" — the queue keeps the playing row in view as the track auto-advances, but only while you're watching it; scroll away to browse and a Jump to current pill brings you back.
    • Album-art thumbnails in every queue row.
    • Clear-queue action.
    • Android drag-to-reorder + per-row remove — closes the functional gap with the web queue (which already had both), plus a header showing track count + total time.

    Verify

    • Web: open a system playlist (e.g. Discover) and press play → it plays, no console error. Open the queue → it's scrolled to the current track; let it advance → it follows; scroll up → the "Jump to current" pill appears.
    • Android: open the Queue from the player → it's positioned on the now-playing track, rows show artwork; drag the grip handle to reorder; tap ✕ to remove; the clear-queue action empties it. (The reorder/pill are new touch interactions — worth a quick real-device sanity check.)

    🤖 Generated with Claude Code

    Downloads
  • v2026.07.15 — UPnP one-cursor unification + queue heart button
    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
    Stable

    bvandeusen released this 2026-07-15 19:17:49 -04:00 | 382 commits to main since this release

    Per-day CalVer, re-cut forward to 47de7be4 (PR #110 + notification-skip follow-up PR #111). Android UPnP/Sonos playback-state rework + a cross-client queue like button.

    Install this APK. The cursor sync + no-flicker behaviour is device-confirmed; this re-cut adds the notification next/prev fix — please re-verify the transport controls.

    UPnP/Sonos — one authoritative "what's playing" (Android, #171)

    While casting, the local ExoPlayer cursor and the Sonos renderer were two competing sources of truth. The local cursor lagged (forward-only, index-based sync switched off during every queue-load / re-cast window) and two writers of the current-track index fought — so the in-app player flickered to the pre-cast track on reopen and the notification metadata went stale.

    The fix completes MinstrelForwardingPlayer into a single source of truth:

    • Identity-locked cursor — the paused local cursor now tracks the song the renderer is actually playing, matched by track identity (the id in the Sonos TrackURI vs the local MediaItem.mediaId), not the raw index. Survives queue-reload index wobble; moves both directions. ✅ device-confirmed: stays in sync, no flicker.
    • One writer — the position tick only moves the scrubber; the current track/index is owned solely by the player-event path. The stale-tick stomp is gone.
    • Handoff for free — ending the cast resumes local playback on the correct track + position.
    • Notification transport now controls Sonos — the system next/prev buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS → Player.seekToNext() / seekToPrevious(), which are distinct from the seekTo*MediaItem() methods the in-app buttons use. Those two were un-overridden, so notification next/prev nudged the paused local player instead of Sonos (they read as dead — play/pause worked because it was routed). Now overridden to route to Sonos while casting. (new in this re-cut)

    Resolves the index-drift half of #1211; builds on the June UPnP foundation (#606 / #608 / #612).

    Verify on device

    1. Sync / no flicker — already confirmed. ✅
    2. Notification/lock-screen transport — while casting, the next and previous buttons (and play/pause) should now drive Sonos.
    3. Handoff — end the cast mid-queue → local resumes on the same track + position.

    Queue like button (web + Android, #1596)

    The queue ("up next") rows in the full-screen player were the only track-list surface without a like heart. Added the shared like button to the web queue rows (QueueTrackRow — covers the /now-playing side panel and the mobile queue drawer) and the Android "Queue" screen rows. Liked state stays sourced from the shared store by track id — no data-model change.

    🤖 Generated with Claude Code

    Downloads
  • v2026.07.14 — Taste-profile fidelity (M160)
    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
    Stable

    bvandeusen released this 2026-07-14 13:37:44 -04:00 | 389 commits to main since this release

    Milestone #160 — Taste-profile fidelity via metadata enrichment (no sidecar) — plus adjacent home/discover polish. Merged via PR #109; main CI green on 4f69c230.

    Taste profile: 2 facets → multi-dimensional. Now scores across artists, enriched genre tags, era, and mood, with a collaborative co-play candidate arm and time/weekday/device context conditioning — all in the single Go+Postgres binary.

    • Folksonomy tag enrichment — MusicBrainz (keyless) + Last.fm (keyed) tag enricher → track_tags cache → tag facet; admin tag-sources card (web + Android); EnrichedTagScale knob.
    • Era facet — decade weights from albums.release_date; era_scale knob.
    • Mood facet — folksonomy tags → canonical mood buckets; mood_scale knob.
    • Time-of-day / weekday context — per-artist affinity for the current daypart × weekday cell; context_time_weight knob.
    • Device-class context — clients send device_class on play-start; radio narrows the affinity cell by current device.
    • Household co-play — SQL co-occurrence over play_events → user_cooccurrence similarity edges → candidate arm (empty on single-user servers).
    • (AcousticBrainz was evaluated and deliberately dropped — frozen 2022 dataset.)

    Also: Songs-like → dedicated home row + wider spread; CoverTile play-button un-clip; discover novelty rebalance; "Updating your mixes…" veil.

    Android: this release builds the first signed APK carrying the admin tag-sources screen (#1521) and the device-class play-event change (#1551). Schema migrations 0042→0048 apply automatically on server restart.

    🤖 Generated with Claude Code

    Downloads
  • v2026.07.02 — Android auto-recovery: failed loads heal on reconnect
    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
    Stable

    bvandeusen released this 2026-07-02 16:45:36 -04:00 | 424 commits to main since this release

    Fixes the "fails once, stays broken forever" behavior (issue #1245, PR #106): a screen that failed to load in a dead zone or during a server restart now heals itself the moment connectivity returns — no more navigating away and back or pull-refreshing to unstick the app.

    Install this APK to get screen-level auto-recovery everywhere.

    Auto-recovery on reconnect

    • New NetworkStatusController.recoveries() signal — the screen-level half of the recovery idiom the sync/mutation/diagnostics singletons already used.
    • Every screen re-runs its load when server health returns: Home, Playlists, Liked, History, Hidden, Requests, Search, Discover, Album/Artist/Playlist detail, and all four admin screens. Direct-load screens only re-fetch from an error state, so good content never flashes a spinner.

    Failure no longer looks like an empty account

    • Cold-start against an unreachable server used to show "Welcome to Minstrel" / "No playlists yet". Home, Playlists, Liked, and Library now surface a real "Couldn't load…" error with a Retry button when the cache is empty and the refresh failed. A failed refresh over cached content stays silent — stale beats a full-screen error.

    Stuck corners unstuck

    • Requests: the 12s progress poll can now escape a failed initial load (it previously required a loaded list to ever fire), which also covers server errors that transport-level health monitoring never sees.
    • Search: re-running the same query after a transient failure now works, and the error state has a Retry button.
    • Artist detail: "Similar artists" / "Top tracks" re-fetch on recovery instead of staying silently absent after a blip.

    Consistent Retry everywhere

    • The shared error widget gained a title and now participates in pull-to-refresh, and it's used on every error branch — every load failure in the app is now recoverable by button, pull, or automatically on reconnect.

    Use it

    Install the APK, then try it: airplane-mode the phone, open a tab so it errors, drop out of airplane mode — the screen should populate itself within seconds of the server probe going healthy.

    🤖 Generated with Claude Code

    Downloads
  • v2026.06.30 — Device diagnostics & debug reporting (M9) + follow-ups
    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
    Stable

    bvandeusen released this 2026-06-30 19:24:58 -04:00 | 427 commits to main since this release

    Per-day mutable CalVer, re-cut forward to 61171515 (M9 + same-day diagnostics follow-ups, PRs #104 #105).

    Toggleable per-account device diagnostics: remotely enable debug mode on an account → its phone(s) stream a timeseries of device state → an admin timestamp view with copy/export for analysis. Targets roaming/poor-data recovery and locked-phone UPnP/Sonos desync.

    Install this APK to get the diagnostics reporter + the follow-up fixes below; the prior build had only the index-level capture.

    Diagnostics core (M9)

    • Migration 0036: diagnostic_events + per-account users.debug_mode_enabled. POST /api/diagnostics (gated batch ingest), GET /api/admin/diagnostics (account/device/kind/time-window filters + export), /diagnostics/devices, PUT /api/admin/users/{id}/debug-mode. 30-day retention.
    • Android DiagnosticsReporter (connectivity, server-health, UPnP, power/Doze/screen, lifecycle, heartbeat) → Room ring buffer → DiagnosticsUploader (offline-buffered, batched). Per-device OFF switch in Settings.
    • Web /admin/diagnostics: timeline (auto-polls), remote toggle, Copy-JSON / Download-NDJSON.

    Same-day follow-ups

    • Web: diagnostics defaults to recent-500; time window under "Advanced"; newest-first sort.
    • playback kind: route + player-state events relabeled out of upnp_sync (they fire for any output); migration 0037.
    • Output-picker fix: connected Bluetooth now reads "Connected" (was "Available") — isConnected no longer derived from the wrong connectionState signal.
    • Per-skip track_change event + track-identity enrichment (local_track_id + sonos_uri) so a re-cast desync is unambiguous; sonos_* zeroed when not casting.

    Use it

    Install the APK, enable debug mode for the account at /admin/diagnostics, open the app on that account, reproduce the issue, then export the window.

    🤖 Generated with Claude Code

    Downloads
  • v2026.06.20 — Image rendering resilience + player error recovery (skip, self-heal, live refresh)
    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
    Stable

    bvandeusen released this 2026-06-20 21:04:04 -04:00 | 438 commits to main since this release

    Per-day CalVer. Cover-art rendering and playback error-recovery hardening across server + web + Android, landed together. Issues #968 (images + player resilience) and #980 (stale-view refresh).

    Covers never sit blank (#968)

    One shared cover renderer per client, so a tile shows a placeholder while loading and a fallback on a 404 instead of an empty box — worst on the You might like row, whose unplayed items often have un-backfilled art.

    • Web: new Cover component owns the loading placeholder + error fallback; AlbumCard / ArtistCard / CompactTrackCard reuse it. ArtistCard previously had no error handler at all.
    • Android: ServerImage now tracks Coil's load state so the per-caller fallback doubles as a placeholder (loading) and an error painter (404), not just a null-URL guard.

    Playback recovers instead of dead-ending (#968)

    • Auto-skip a failed track (web + Android): a track that won't load advances to the next instead of stranding the player on "Try again" (web) / a stalled queue (Android). The error state only appears once the whole queue is unplayable. On Android the "Skipped N unplayable tracks" snackbar now tells the truth — it was firing while nothing actually skipped.
    • Self-heal a stale queue: when the whole queue is unplayable — e.g. a tab/app left open across the daily 03:00 rebuild — the player re-pulls its source (a system playlist via the rotation-aware shuffle, or a radio re-seed) and resumes. Bounded to one re-pull per exhaustion so a still-broken refresh can't loop.

    Browse views refresh themselves (#968)

    The server now emits playlist.system_rebuilt on the daily rebuild and on manual refresh. The web client gained its first inbound SSE listener and Android consumes it via EventsStream — both refresh the Home + playlist views, so a tab open since yesterday stops showing yesterday's mix.

    Stale-view refresh on an open mix (#980)

    A system-playlist detail screen open across the rebuild shows a "This mix was refreshed · Refresh" banner (web) / snackbar (Android). Refresh re-resolves the rotated playlist id to the fresh mix — no forced redirect, no auto-reload, you refresh on your terms.

    Verify

    • Open Home (web or Android) with un-backfilled art → cover tiles show a placeholder then a fallback, never a blank box.
    • Play a queue containing a dead/removed track → it skips to the next instead of dead-ending; only an all-unplayable queue surfaces "Try again".
    • Leave a For You tab open across the 03:00 rebuild → the Home tiles refresh on their own; if you then play the stale queue it re-pulls the fresh mix.
    • Open a system-playlist detail screen and trigger a refresh → a "This mix was refreshed · Refresh" prompt appears; Refresh lands on the new mix.

    🤖 Generated with Claude Code

    Downloads
  • v2026.06.12 — UPnP/Sonos cast resilience (lock, drop-suppression, session adopt) + recommendation tuning
    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
    Stable

    bvandeusen released this 2026-06-12 21:26:09 -04:00 | 462 commits to main since this release

    Per-day mutable CalVer, re-cut forward to 3e258507. A full day of Android UPnP/Sonos cast hardening on top of the morning's recommendation batch.

    Cast survives a locked screen (PR #97)

    While casting, the wrapped ExoPlayer is paused, releasing its WAKE_MODE_NETWORK WiFi/wake locks — so on a locked, on-battery phone the WiFi power-saved within seconds and starved the liveness poll, dropping a perfectly-healthy speaker. New CastNetworkLock (high-perf/low-latency WifiLock + partial WakeLock) held for the lifetime of a cast.

    Don't drop the speaker (or blast local audio) on a phone-side network gap (PR #98)

    A transient ServerDown gap (WiFi still associated) tripped the drop threshold and fell back to the local player — which started playing the song out of the phone while the Sonos was still streaming it. The drop is now gated on NetworkStatusController: only drop when the phone's network is Healthy. During a phone-side outage the route is held and the poll reconciles on recovery (a local fallback couldn't reach the server either).

    Adopt a running session instead of clear+reload (PR #98)

    Selecting a speaker that's already playing your queue (a session the phone got disconnected from but the speaker kept streaming) now attaches in place — syncs to its live track/position, no queue wipe, no jarring restart — and skip/seek drive its live queue. Falls through to the full reload only when it isn't your queue.

    Anti-stickiness + faster recovery (PR #98)

    • Powered-off speakers now leave the picker (lastSeen + post-scan prune) and the selection reverts to the phone, so a later play never targets a ghost route.
    • On network recovery, a held cast reconciles immediately rather than waiting for the next poll tick.

    Verify on device — on battery (USB masks the network-gap class)

    • Cast → unplug → lock → keeps playing.
    • Cast → brief network blip → Sonos keeps playing, phone does not blare local audio, reconciles on recovery.
    • Phone dropped but Sonos still playing → re-select the group → attaches to the live session, skip works (no restart).
    • Power a speaker off → reopen the picker → it disappears; selection falls back to phone.

    Also in this release (recommendation tuning)

    • You-might-like fallback (#790), taste 2b (taste_overlap arm), recommendation metrics (Settings → Recommendation metrics). Deferred: taste phase 3 (context).

    🤖 Generated with Claude Code

    Downloads
  • v2026.06.11 — History repair, offline robustness, recommendations
    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
    Stable

    bvandeusen released this 2026-06-11 23:34:02 -04:00 | 471 commits to main since this release

    Per-day mutable CalVer, moved forward to 7b7bd0c3 (PR #95). Adds a
    liked-entity fallback so the "You might like" rows fill out for heavy
    listeners.

    You-might-like fallback (this re-cut — PR #95)

    The taste roll-up surfaces top-similar albums/artists, which for a heavy
    listener are mostly ones they already play — so the dedup (vs Most Played +
    Rediscover + Last Played) could strip the section to a single tile. When a row
    comes up short, it now tops up from your liked artists/albums (a much larger
    pool), reusing the same exclusions so nothing is shown twice or suggests an
    actively-played entity. Read-side — fills immediately after deploy, no rebuild.

    "You might like" — on web + Android (#790)

    A You might like block (albums + artists) directly under the system
    playlists row on web and Android, from the server's daily-built, taste-aware
    recommendations + the liked fallback above.

    Taste profile — Minstrel learns taste (#796)

    Persistent, decaying per-user taste model that re-ranks every recommendation
    surface. Graded engagement (completion/skip, time-decayed); no dislike button
    (passive, track-scoped); a TasteMatch scoring term toward learned taste.

    Earlier in this release

    History repair + offline robustness (PR #90), server rows + taste profile
    (PR #91), Android row (PR #92), repositioned (PR #93), web row (PR #94).

    Verify

    • Redeploy this :latest. On Home (web or Android), the You might like
      section sits under the playlists row. With a decent number of likes it now
      fills out (no longer a single tile) immediately — the liked fallback is
      read-side, so no rebuild/gate wait is needed for it.

    🤖 Generated with Claude Code

    Downloads