Audio cache: just_audio LockCachingAudioSource with tiered LRU eviction; CachedIndicator + Download buttons across track surfaces; Settings → Storage card
Wire-format fix in this batch: discovered the sync handler emitted PascalCase JSON but Flutter reads snake_case — entire metadata cache had been writing empty rows. Closed via library_sync_views.go view structs + pinned wire-key tests.
Server /api/client/version + /api/client/apk endpoints (auth-gated, APK rate-limited per user 60s)
Flutter banner mounts in shell, polls every 24h, downloads via dio, fires Android PackageInstaller intent through MainActivity MethodChannel + FileProvider
CI sequencing: release.yml polls the Forgejo release page for the APK that flutter.yml attached, drops it into client/, Dockerfile bakes it into the image at /app/client/
Web MobileAppDownload component on Settings → Mobile app; renders nothing when no APK bundled
Heads-up for the test pass
First sync after upgrading: drift gets fully populated for the first time. Pre-this-PR images had a broken sync wire format; offline browse showed empty rows. After deploying this image and the Flutter app re-runs sync from cursor 0, the cache should populate correctly.
Flutter drift schema bumps from v1 → v2 for the systemVariant column; existing installs auto-migrate via m.addColumn + cursor reset.
In-app update flow is end-to-end testable for the first time with this release — see #397 closeout note in Fable for the full first-tag-release walkthrough.
Test plan
CI on the PR passes (Go + web + Flutter)
Tag release after merge — image build should bundle the matching APK
On-device walkthrough: offline browse, add-to-playlist sheet (no system playlists), stream resilience under network blip, requests view + cancel, mobile responsive at 375/414/768
PR brings `dev` (~555 commits, 2026-04-29 → 2026-05-10) into `main` for a real-device test release.
## Major themes
### M5 / M6 / M7 server + web (Apr 29 → May 8)
- M5a Lidarr integration (typed client, request/quarantine flows, admin UI)
- M5b/c recommendation service + nav restructure
- M6a request-progress counters + scan admin
- M7 frontend sweep: branding (#363/#377), queue UI (#363), history (#364), playlists (#352 slices 2-3), cards/MBID extraction (#382), TheAudioDB artist art, in-flight tallies UI
- M7 user management (#376 — first-registered-user is admin, promote/demote, invites)
- M7 search filter chips (#368), track-actions menu (#372), theme toggle (#362)
### Flutter offline mode (#357, plans A/B/C)
- Server: append-only `library_changes` log + cursor-based delta sync at `/api/library/sync`
- Flutter: drift cache (`cached_artists`/`albums`/`tracks`/`likes`/`playlists`/`playlist_tracks`), SyncController, `cacheFirst` reactive helper, drift-first migration of 7 cached-entity providers
- Audio cache: just_audio LockCachingAudioSource with tiered LRU eviction; CachedIndicator + Download buttons across track surfaces; Settings → Storage card
- **Wire-format fix in this batch**: discovered the sync handler emitted PascalCase JSON but Flutter reads snake_case — entire metadata cache had been writing empty rows. Closed via `library_sync_views.go` view structs + pinned wire-key tests.
### Flutter parity (#356)
- Search, Discover (Lidarr search + create requests), Queue UI, Library 5-tab, Settings (Profile / Appearance / Storage / Password / ListenBrainz / Admin), Playlists list+detail, Track-actions sheet, Admin (quarantine + requests + users), Listening history
- User-facing requests view + cancel (closes the cleanest remaining gap)
- systemVariant column + drift schema bump (closes the v1 limitation in plan C)
### Web mobile responsive (#358)
- Hamburger nav + MobileNavDrawer (off-canvas, focus trap, esc close)
- PlayerBar 2-row mobile reflow + PlayerOverflowMenu (shuffle/repeat/queue/volume)
- RowActionsMenu generic primary+⋮ for all 4 admin tables
- AlphabeticalGrid h3 dividers (a11y borrow), HorizontalScrollRow coarse-pointer arrows
### Web DRY pass (#375)
- Test-utils batch (`mocks/likes`, `mocks/quarantine`, `mocks/appState`, `fixtures/track`) — ~46 test files swept
- `playlists.ts` migrated to `api.*` wrapper; `api.del` made generic
- `safeLocalStorage`, `playlistTrackToRef`, `runMutation`, `CardActionCluster` helpers
- Theme-color hex DRYed to tokens.json
- Auth ↔ player import cycle fully inverted via `sessionEnd` signal
### Web stream resilience (#369 + this batch)
- `preload="auto"` on the audio element
- `attachStallRetry` — auto-recovery from `stalled` / `waiting` / network errors with bounded retries
- Hidden prefetch `<audio>` for the next track (gap-free transitions)
- `audioLoader` seam — Tauri shell can later swap to a Rust-backed cache without touching the player UI
- Auto-poll `/requests` + `/admin/requests` every 12s while ingests in-flight (#369 piece)
### In-app update flow (#397)
- Server `/api/client/version` + `/api/client/apk` endpoints (auth-gated, APK rate-limited per user 60s)
- Flutter banner mounts in shell, polls every 24h, downloads via dio, fires Android `PackageInstaller` intent through MainActivity MethodChannel + FileProvider
- CI sequencing: `release.yml` polls the Forgejo release page for the APK that `flutter.yml` attached, drops it into `client/`, Dockerfile bakes it into the image at `/app/client/`
- Web `MobileAppDownload` component on Settings → Mobile app; renders nothing when no APK bundled
## Heads-up for the test pass
- **First sync after upgrading: drift gets fully populated for the first time.** Pre-this-PR images had a broken sync wire format; offline browse showed empty rows. After deploying this image and the Flutter app re-runs sync from cursor 0, the cache should populate correctly.
- **Flutter drift schema bumps from v1 → v2** for the systemVariant column; existing installs auto-migrate via `m.addColumn` + cursor reset.
- **In-app update flow is end-to-end testable for the first time** with this release — see #397 closeout note in Fable for the full first-tag-release walkthrough.
## Test plan
- [ ] CI on the PR passes (Go + web + Flutter)
- [ ] Tag release after merge — image build should bundle the matching APK
- [ ] On-device walkthrough: offline browse, add-to-playlist sheet (no system playlists), stream resilience under network blip, requests view + cancel, mobile responsive at 375/414/768
- [ ] In-app update: existing 0.1.0 client → see banner → install → upgrade
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
PR brings
dev(~555 commits, 2026-04-29 → 2026-05-10) intomainfor a real-device test release.Major themes
M5 / M6 / M7 server + web (Apr 29 → May 8)
Flutter offline mode (#357, plans A/B/C)
library_changeslog + cursor-based delta sync at/api/library/synccached_artists/albums/tracks/likes/playlists/playlist_tracks), SyncController,cacheFirstreactive helper, drift-first migration of 7 cached-entity providerslibrary_sync_views.goview structs + pinned wire-key tests.Flutter parity (#356)
Web mobile responsive (#358)
Web DRY pass (#375)
mocks/likes,mocks/quarantine,mocks/appState,fixtures/track) — ~46 test files sweptplaylists.tsmigrated toapi.*wrapper;api.delmade genericsafeLocalStorage,playlistTrackToRef,runMutation,CardActionClusterhelperssessionEndsignalWeb stream resilience (#369 + this batch)
preload="auto"on the audio elementattachStallRetry— auto-recovery fromstalled/waiting/ network errors with bounded retries<audio>for the next track (gap-free transitions)audioLoaderseam — Tauri shell can later swap to a Rust-backed cache without touching the player UI/requests+/admin/requestsevery 12s while ingests in-flight (#369 piece)In-app update flow (#397)
/api/client/version+/api/client/apkendpoints (auth-gated, APK rate-limited per user 60s)PackageInstallerintent through MainActivity MethodChannel + FileProviderrelease.ymlpolls the Forgejo release page for the APK thatflutter.ymlattached, drops it intoclient/, Dockerfile bakes it into the image at/app/client/MobileAppDownloadcomponent on Settings → Mobile app; renders nothing when no APK bundledHeads-up for the test pass
m.addColumn+ cursor reset.Test plan
🤖 Generated with Claude Code