Skip to content

SPA auto-update on new build, preserving player + queue state (#196) - #265

Merged
benders merged 3 commits into
mainfrom
feature/196-auto-update
Jul 17, 2026
Merged

SPA auto-update on new build, preserving player + queue state (#196)#265
benders merged 3 commits into
mainfrom
feature/196-auto-update

Conversation

@benders

@benders benders commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Closes #196.

What

The SPA now detects a new deployed build and reloads itself without losing the play queue — and without ever interrupting audio the user can hear.

Hub: GET /api/version

  • Cheap, unauthenticated poll target: { appVersion, buildId } — no Navidrome ping (unlike /api/health, whose 1s timeout budget makes it unsuitable for per-tab polling).
  • buildId = short SHA-256 of the on-disk ${PUBLIC_DIR}/index.html (mtime+size cached, ~one stat per request). Vite content-hashes every chunk name into index.html, so any rebuild changes the id — no APP_VERSION bump, no hub restart (owner requirement: most changes don't increment the version).
  • Sentinels: "dev" (no staticDir — Vite HMR owns dev updates), "unknown" (index.html unreadable, e.g. mid-deploy). Neither ever triggers a reload.

SPA: UpdateNotifier (mounted in AppLayout, post-login only)

  • Baseline = first successful poll after boot (nothing baked into the bundle); polls every 60s + on window focus via react-query.
  • Any difference = update (hashes have no ordering; rollbacks propagate too).
  • Auto-reload when safe: paused, or casting to Sonos (device keeps playing — the tab is just a remote), and no text input focused. Otherwise a subtle "Update available" banner with a Reload button, re-checked every 5s so the deferred reload lands when playback pauses/ends.
  • Loop guard: sessionStorage remembers the buildId already reloaded for — a persistent mismatch degrades to the manual banner instead of reload-looping.

State preservation across the reload

  • lib/player-snapshot.ts: queue/index/position/shuffle/repeat/isPlaying/sink → sessionStorage, restored at boot if < 30s old (volume already persists via localStorage).
  • Sonos restore seeds PlayerBar's existing skip-play ref, so mount doesn't re-issue SetAVTransportURI at the still-playing device.
  • Local resume rides the existing audio.play() path; an autoplay-policy rejection lands the player paused at the correct position.
  • The sink restore is a documented, freshness-bounded exception to the player store's never-persist rule.

Skipped the configVersion idea from the issue thread — /api/capabilities is already re-polled via react-query, so admin toggles propagate without a reload.

Testing

  • hub/test/version-route.test.ts (7): buildId hashing, mtime-cache invalidation on rewrite, dev/unknown sentinels, endpoint shape.
  • frontend/src/lib/player-snapshot.test.ts (7): round-trip, freshness expiry, corrupt/malformed payloads, one-shot Sonos flag, unknown-sink fallback.
  • frontend/src/components/UpdateNotifier.test.tsx (7): stable = silent, auto-reload when paused / when casting, defer + manual reload while playing locally, deferred reload fires on pause, loop guard, dev/unknown ignored.
  • pnpm verify green (826 hub + 166 frontend), pnpm lint zero output.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UkCqNJ2T4qMyBYStie6hhS

claude added 2 commits July 16, 2026 07:43
Lightweight version signal for SPA auto-update polling. Unlike
/api/health (1s Navidrome ping budget per call), this endpoint is cheap
enough to poll per open tab. buildId is a short SHA-256 of the on-disk
${PUBLIC_DIR}/index.html behind an mtime+size cache, so any rebuild
dropped on disk is detected without an APP_VERSION bump and without a
hub restart. Sentinels: 'dev' (no staticDir), 'unknown' (unreadable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkCqNJ2T4qMyBYStie6hhS
…loses #196)

UpdateNotifier polls /api/version (60s + window focus) and treats any
buildId different from the boot-time baseline as an update. Auto-reload
fires when it can't interrupt anything: playback paused, or sink is
Sonos (the device keeps playing — the tab is just a remote), and no
text input focused. While playing locally it defers behind a subtle
'Update available' banner with a manual Reload button, retrying every
5s so the reload lands when playback pauses or ends.

Player state (queue, index, position, shuffle/repeat, isPlaying, sink)
is snapshotted to sessionStorage before reload and restored at boot
within a 30s freshness window. The sink restore is a documented scoped
exception to the store's never-persist rule; a restored Sonos sink
seeds PlayerBar's skip-play ref so the mount-time track-change effect
doesn't re-issue SetAVTransportURI at the still-playing device. Local
resume goes through the existing audio.play() path — an autoplay-policy
rejection lands the player paused at the right position.

Reload-loop guard: sessionStorage remembers the buildId already
reloaded for; a persistent mismatch (cached index.html) degrades to the
manual banner instead of reload-looping the tab. 'dev'/'unknown'
buildIds never trigger anything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkCqNJ2T4qMyBYStie6hhS
@benders
benders marked this pull request as ready for review July 17, 2026 00:05
@benders
benders merged commit 3ca8f1f into main Jul 17, 2026
2 checks passed
@benders
benders deleted the feature/196-auto-update branch July 21, 2026 06:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SPA: auto-update on new version while preserving player + queue state

2 participants