Headline of S4. "Shuffle all" is always present (home app-bar
shuffle icon); the pool degrades with reachability:
- online → GET /api/library/shuffle?limit=N (new): N random
library tracks server-side, per-user quarantine filtered
(ListRandomTracksForUser, ORDER BY random()).
- offline → ShuffleSource shuffles the whole local cache index
(audio_cache_index ∩ cached_tracks, names from cached_artists/
albums) — a UNION over liked AND recently-played, since the
two-bucket split is storage-only and never filters playback.
Offline gating: refreshable (singleton) system playlists need the
live build/shuffle endpoints, so their tile play is disabled when
offlineProvider is true — Shuffle all is the offline path. User
playlists still play from cache.
playlist_card now reads offlineProvider in build → wrapped the
direct-render widget test in ProviderScope (offlineProvider is
smoke-safe from S1: tracked timers, no connectivity mount).
S4b (offline Recently-played / Liked browsable surfaces over the
cache index) next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-kind refresh/shuffle handlers with one generic
pair driven off the kind registry, in lockstep across both clients.
Server:
- systemPlaylistKind gains Singleton; RefreshableSystemKind(key)
exported. for_you/discover singleton; songs_like_artist not.
- New generic POST /api/playlists/system/{kind}/refresh and
GET /api/playlists/system/{kind}/shuffle ({kind} = raw
system_variant). Non-singleton/unknown kind → 404. Deleted
playlists_{foryou,discover}_refresh.go and the per-kind shuffle
wrappers; serveSystemPlaylistShuffle core kept.
- playlistRowView.refreshable: server-derived flag so clients show
the refresh affordance generically without hardcoding kinds.
Web (not drift-cached → uses the server flag):
- refreshSystem(variant) replaces refreshForYou/refreshDiscover;
systemShuffle drops the for_you→for-you mapping (raw variant).
- PlaylistCard + detail page gate the kebab/Refresh button on
playlist.refreshable; label is "Refresh {name}". Tests reworked;
obsolete refresh-foryou/discover api tests deleted.
Flutter (list tiles are drift-cache-sourced → derive, no migration):
- Playlist.refreshable getter = isSystem && variant !=
songs_like_artist (holds for all current + planned kinds).
- refreshSystem/systemShuffle use the raw variant; PlaylistCard +
detail screen gate kebab/Regenerate/source-tagging on refreshable
so songs_like_artist plays via get() (no by-kind endpoint).
Pure-plumbing refactor; CI verifies parity. Next (R3): the five
discovery mixes — each a candidate query + one registry entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flutter half of offline-replay capture. Play events no longer
fire-and-forget: the reporter now tracks each play as a completed
unit (track, original start time, source, duration reached)
independently of connectivity.
- EventsApi.playOffline: single timestamp-preserving call → the new
/api/events play_offline (47aa178).
- MutationQueue: new play.offline kind + handler (EventsApi).
- PlayEventsReporter rework:
- _beginTrack captures start context + fires live play_started;
the server id is adopted only if it lands while still on-track.
- position progress gated on the tracked track id so a track
change can't clobber the finishing track's last values.
- _closeCurrent: if a server id registered, attempt the live
ended/skipped and fall back to the offline queue on failure; if
no id (offline start) enqueue the completed play directly. The
server applies the canonical skip rule, so the offline payload
only carries duration.
- app paused/detached closes durably via the queue (survives a
process kill; a teardown POST would not).
Result: listening to cached tracks fully offline now records
history / recs / scrobble / #415 rotation once back online, with
the original timestamps. Web stays best-effort by standing
occasional-use scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Flutter client previously reported NO plays — mobile listening
never reached play_events, so history, recommendation scoring,
ListenBrainz scrobbles, and #415 rotation all missed mobile entirely.
Operator chose to close that gap properly as part of Stage 3.
New:
- EventsApi (api/endpoints/events.dart): play_started/ended/skipped.
- PlayEventsReporter (player/play_events_reporter.dart): state
machine over (track id, playing) mirroring the web dispatcher.
Persists an opaque client_id in secure storage. Deliberate
divergence from web: a track change inside a queue is classified
ended-vs-skipped by whether the prior track reached ~its duration
(3s tolerance), instead of web's blanket "track change = skip"
which would mark every naturally-finished in-queue track a skip
and dilute recommendation skip-ratios — the exact failure mode
that motivated doing this properly. Fail-safe: no-ops when there's
no audio handler (tests / no-audio env). App-lifecycle paused/
detached closes an open row as a best-effort skip (web pagehide
parity). Wired in app.dart postFrame.
- PlaylistsApi.systemShuffle(variant): GET the rotation-aware order.
Wiring:
- audio_handler: _queueSource carried through setQueueFromTracks
(source param); preserved across internal skipToQueueItem rebuild.
- player_provider.playTracks: source param → setQueueFromTracks.
- PlaylistCard: system playlists fetch systemShuffle and play as-is
tagged with source (no client shuffle — server already ordered).
- playlist_detail_screen: header Play + per-track tap tag source for
system playlists so rotation advances from any entry point.
Known/flagged separately: the web dispatcher likely has the same
false-skip-on-advance issue; not fixed here to keep #415 scoped and
clients' wire behavior comparable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Flutter half of Fable #416. Web got a generalized
system-playlist refresh kebab in d12afda; this brings Flutter to
parity instead of leaving the affordance web-only.
- PlaylistsApi.refreshSystem(variant): POST
/api/playlists/system/{for-you|discover}/refresh, maps the
underscore model variant to the hyphenated route segment,
returns the rotated playlist id.
- PlaylistCard: top-right PopupMenuButton on system playlists
with a context-labelled "Refresh For You" / "Refresh Discover"
item. Calls refreshSystem, invalidates playlistsListProvider
(which reconciles the rotated UUID + new tracks), snackbars
the result. ScaffoldMessenger captured pre-await.
- Tests: kebab present for system, absent for user playlists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Periodic worker that walks cached_artists for missing album lists
and cached_albums for missing track lists, then fills them via
/api/artists/:id + /api/albums/:id. Newly-discovered album covers
are pre-warmed into flutter_cache_manager's disk cache too.
Solves the "tap an artist → empty album area → pop in" experience:
artistAlbumsProvider was drift-first but the cache only got
populated when the user navigated TO an artist. SyncController's
/api/library/sync delta doesn't carry per-artist album lists (those
are query-time derived). Now the filler pre-populates them in the
background so the drift hit is real on first tap.
Pacing (intentionally conservative):
* 10-second initial delay so SyncController has time to land its
first sync — the WHERE NOT EXISTS query has nothing to do until
cached_artists is populated.
* 5-minute interval thereafter. Once steady state is reached the
sweep is a cheap drift query + early exit.
* 200ms throttle between per-entity REST requests — never competes
with active playback.
* 200 entities per sweep cap so a fresh install with thousands of
artists doesn't tie up the network for one continuous run. Next
sweep picks up where this one left off (NOT EXISTS naturally
skips already-filled rows).
Wall time estimate for a 1000-artist library: ~3-4 minutes spread
over multiple sweeps. Single round-trip per artist (new
getArtistDetail API method returns artist + albums in one shot).
Activated from app.dart's postFrameCallback alongside the existing
SyncController / Prefetcher / MetadataPrefetcher / LiveEvents
hooks. Disposed via ref.onDispose when the provider scope tears
down (effectively process death in practice).
End-to-end pilot of the per-item architecture. Home now reads from
the new homeIndexProvider (drift-first over CachedHomeIndex with
/api/home/index discovery + SWR), then each tile is a small
ConsumerWidget watching its own albumTileProvider/artistTileProvider/
trackTileProvider. Tiles render a matched-dimension skeleton while
their entity is still hydrating, and swap in the real card once
drift emits the populated row.
Track hydration is wired up — /api/tracks/:id already existed so
the queue's case 'track' just calls api.getTrack(id) and persists.
The visible behavior:
* Cold visit: small /api/home/index round-trip (IDs only, ~10×
smaller than /api/home), then sections appear shaped with
skeleton tiles; each tile materializes as the hydration queue
drains. No more "30s blank → everything pops in at once."
* Warm visit: drift index emits instantly, drift entity rows emit
instantly, no network. Page paints fully in the first frame.
* Mid-state: scrolling through a partially-hydrated section sees
real cards next to skeleton cards. Layout doesn't shift because
skeletons match real-card dimensions exactly.
CachedHomeSnapshot (and the legacy homeProvider) stay in place but
unconsumed by Flutter — left in for now so revert is cheap if the
new path needs reworking. Cleanup follow-up in a later slice.
Old /api/home endpoint untouched, so the web client keeps working
unchanged.
Flutter client posts FlutterTimezone.getLocalTimezone() to
PUT /api/me/timezone on every setSession (login / register success)
and on every AuthController.build (app cold-start with valid
session), when the locally-stored tz_last_sent_at is >7 days old.
Cadence tracked in flutter_secure_storage so it survives app
restarts.
Failures swallowed: the server's UTC default + last-known value
keep the scheduler functioning until the next attempt.
Completes the client side of #392 Half B (per-user timezone
scheduling).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- artist_detail_screen.dart missed an `import '../models/artist.dart'`
for the ArtistRef seed parameter; analyze flagged undefined_class.
- radio.dart + player_provider.dart doc comments wrapped URL
parameters in <...> which lint reads as HTML. Switched to backticks.
Full player title is now centered absolutely via a Stack: title with
horizontal padding equal to the actions cluster width sits at the
optical center, while LikeButton + TrackActionsButton are pinned to
the right edge with Positioned. Fixed-height SizedBox(32) keeps the
row stable when the title wraps to a single ellipsized line.
Three issues, all related to the player surface:
1. Player UI didn't update on track change. audio_handler's
_onCurrentIndexChanged only kicked off the cover load — it never
pushed the new MediaItem onto the mediaItem stream. Title/artist/
cover stayed pinned to whatever setQueueFromTracks(initialIndex:)
set on first play. Now the listener pushes queue[idx] when the
index changes.
2. Player kebab "Go to artist" 404'd while the same item from
MostPlayed worked. Same TrackActionsSheet for both, but the
player's _trackRefFromMediaItem was hardcoding artistId: ''
because audio_handler's _toMediaItem never stashed it in extras.
Stash artist_id alongside album_id; player_bar +
now_playing_screen read it back. Both kebabs now navigate.
3. "Start radio" didn't exist on Flutter even though the server has
/api/radio?seed_track=<id>. New RadioApi (lib/api/endpoints/
radio.dart) wraps the endpoint; PlayerActions.startRadio(trackId)
fetches + plays the result via the existing playTracks path.
New menu item between "Add to playlist" and the divider above
"Go to album", calls startRadio with a snackbar error fallback.
Artists tab 404: client called /api/library/artists, but the server
mounts the artists list at /api/artists (handleListArtists). Albums
sit at /api/library/albums for historical reasons — the paths aren't
symmetric. Switch listArtists() to /api/artists with sort=alpha.
Albums tab grid: matched the responsive 3-up layout we built for
artist detail. LayoutBuilder computes cellW from available width;
AlbumCard sized to the cell with titleMaxLines: 2; mainAxisExtent
matches actual content height (cover + 2-line title + artist line +
fudge). No more wide-aspect cells with empty space below the card.
Also wired `extra: ref` on the artists/albums grids and the Liked
tab so detail-screen nav hydration kicks in here too — taps from
library screens get the same instant header that home tiles do.
Closes the cleanest remaining #356 gap — Discover submits Lidarr
requests but had no surface for users to view or cancel their own.
Admin had it; user didn't.
- RequestsApi at lib/api/endpoints/requests.dart — listMine() +
cancel(id). Same /api/requests endpoint the web /requests page
uses; identical AdminRequest wire shape so the existing model
is reused (just augmented with matched_*_id fields for the
"Listen" CTA on completed rows).
- MyRequestsController mirrors the web's auto-poll (#369 piece
already shipped server-side / web-side): 12s refresh while any
row is mid-ingest (status='approved'), stops on settle. Riverpod
Timer in build() that's cancelled in onDispose.
- RequestsScreen with kind/status pills, ingest progress copy,
Cancel (with confirm dialog) and Listen CTAs per row state.
- Route /requests under the shell. Entry point in settings between
Profile and Appearance — "My requests".
- Widget tests cover empty / pending / completed / rejected states +
the Cancel-confirm dialog.
Out of scope this slice: Discover-screen "View your requests" CTA
after submitting a new request. Reasonable follow-up but doesn't
block the management loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small data-layer additions for the upcoming track-actions menu:
- PlaylistsApi.appendTracks(playlistId, trackIds) wraps
POST /api/playlists/{id}/tracks for the "Add to playlist" action.
- audio_handler gains playNext (insertAudioSource at currentIndex+1)
and enqueue (addAudioSource) — both also push the audio_service
queue notifier so the queue-screen UI stays in sync.
- The AudioSource construction was extracted into a private
_buildAudioSource helper so setQueueFromTracks / playNext / enqueue
share one source-building path.
- PlayerActions exposes playNext / enqueue for menu use.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dio client for /api/quarantine — flag a track with a reason + optional
notes, unflag by track id. Mirrors the server's user-scoped quarantine
endpoints (separate from /admin/quarantine).
Used by the track-actions menu's Hide/Unhide action in the next slice
of commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the model + MeApi.systemPlaylistsStatus() for the home Playlists
row's placeholder-variant logic (building / failed / pending /
seed-needed). Mirrors GET /api/me/system-playlists-status which
returns the caller's most recent system_playlist_runs row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The server has always returned an enveloped response from GET
/api/playlists; the existing client decoded it as a flat List which
fails at runtime against the actual Map shape. Existing playlists
list screen would have been broken on any production instance.
list() now returns PlaylistsList { owned, public }. The existing
list screen consumes lists.all (owned + public flattened) — same
visible output as the previous flat-list assumption.
This unblocks the home parity slice which needs the same endpoint
for the new Playlists row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four small Dio-backed API classes mirroring the server's actual
contract (verified against internal/api/admin_*.go):
- Requests/Quarantine return flat lists; Users/Invites are enveloped
({"users": [...]}, {"invites": [...]}) and unwrap here
- setAutoApprove sends {auto_approve: bool} — different from the
response field name auto_approve_requests
- resetPassword takes admin-supplied {password: string}, returns 204
- Invite create takes optional {note: string}; expiry is server-fixed
at 24h
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- models/playlist.dart (Playlist, PlaylistTrack, PlaylistDetail)
- api/endpoints/playlists.dart (list with kind=user|system|all, get detail)
- playlists/playlists_provider.dart (Riverpod family providers)
- playlists/playlists_list_screen.dart (with system-variant pill)
- playlists/playlist_detail_screen.dart (header + Play button + rows)
- /playlists + /playlists/:id routes wired
- Home AppBar: queue_music icon next to Search
Tracks with track_id=null render greyed-out + struck-through (matches
web's PlaylistTrackRow behavior). Tap a row plays from that position;
top-level Play button plays the full playable subset from track 0.
LikedIdsController loads /api/likes/ids once and mutates the local set
on toggle. Failed mutations roll the set back so the icon never lies.
Wired into ArtistDetail header, AlbumDetail header, and per-track in
the album track list.
LibraryApi wraps GET /api/home, /api/artists/{id}(/tracks),
/api/albums/{id}. dioProvider builds an authenticated dio (token
resolver reads session_token from secure storage on every request).
homeProvider, artistProvider(id), albumProvider(id) sit on top.
Login uses a non-authenticated dio (token resolver returns null) since
we don't have one yet. On success, setSession persists token + user
into secure storage and the AuthController state flips, which the
router watches to navigate.
AuthController is an AsyncNotifier holding the currently-logged-in
User. server_url, session_token, current_user all live in
flutter_secure_storage. ServerUrlScreen probes /healthz before saving
the URL so we don't store a bogus base.