From a5e2abb8c42ffad9c77a7b81f5f274bd8a9c5adc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 15 May 2026 21:37:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(offline):=20#427=20S4a=20=E2=80=94=20Shuff?= =?UTF-8?q?le=20all=20+=20offline=20system-playlist=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- flutter_client/lib/api/endpoints/library.dart | 13 ++++ flutter_client/lib/cache/shuffle_source.dart | 68 +++++++++++++++++++ flutter_client/lib/library/home_screen.dart | 25 ++++++- .../lib/playlists/widgets/playlist_card.dart | 8 ++- .../playlists/widgets/playlist_card_test.dart | 21 +++--- internal/api/api.go | 1 + internal/api/library.go | 38 +++++++++++ internal/db/dbq/tracks.sql.go | 67 ++++++++++++++++++ internal/db/queries/tracks.sql | 17 +++++ 9 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 flutter_client/lib/cache/shuffle_source.dart diff --git a/flutter_client/lib/api/endpoints/library.dart b/flutter_client/lib/api/endpoints/library.dart index e5cfff71..53895b7f 100644 --- a/flutter_client/lib/api/endpoints/library.dart +++ b/flutter_client/lib/api/endpoints/library.dart @@ -85,6 +85,19 @@ class LibraryApi { .toList(growable: false); } + /// GET /api/library/shuffle?limit=N (#427 S4). N random library + /// tracks — the online source for "Shuffle all". Bare JSON array. + Future> shuffle({int limit = 100}) async { + final r = await _dio.get>( + '/api/library/shuffle', + queryParameters: {'limit': limit}, + ); + final raw = r.data ?? const []; + return raw + .map((e) => TrackRef.fromJson((e as Map).cast())) + .toList(growable: false); + } + /// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we /// type the response as `List` rather than a Map envelope. Future> getArtistTracks(String id) async { diff --git a/flutter_client/lib/cache/shuffle_source.dart b/flutter_client/lib/cache/shuffle_source.dart new file mode 100644 index 00000000..5188a5fd --- /dev/null +++ b/flutter_client/lib/cache/shuffle_source.dart @@ -0,0 +1,68 @@ +// "Shuffle all" source (#427 S4). Always-present; the pool degrades +// gracefully with reachability: +// online → GET /api/library/shuffle (random over the whole library) +// offline → a client shuffle over the entire local cache index +// +// Offline is a UNION over every cached track regardless of storage +// bucket — liked AND recently-played both included. The two-bucket +// split (S2) is storage/eviction-only and never filters playback, +// which is exactly the property this relies on. + +import 'dart:math'; + +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../library/library_providers.dart' show libraryApiProvider; +import '../models/track.dart'; +import 'adapters.dart'; +import 'audio_cache_manager.dart' show appDbProvider; +import 'offline_provider.dart'; + +class ShuffleSource { + ShuffleSource(this._ref); + final Ref _ref; + + /// Returns up to [limit] tracks to play as a shuffle-all. Online: + /// server-random. Offline: every cached track (liked + recent), + /// shuffled client-side. Empty offline → nothing is cached yet. + Future> tracks({int limit = 100}) async { + if (_ref.read(offlineProvider)) { + return _offline(limit); + } + final api = await _ref.read(libraryApiProvider.future); + return api.shuffle(limit: limit); + } + + Future> _offline(int limit) async { + final db = _ref.read(appDbProvider); + final cachedIds = + (await db.select(db.audioCacheIndex).get()).map((r) => r.trackId).toSet(); + if (cachedIds.isEmpty) return const []; + + final meta = await (db.select(db.cachedTracks) + ..where((t) => t.id.isIn(cachedIds.toList()))) + .get(); + if (meta.isEmpty) return const []; + + final artistName = { + for (final a in await db.select(db.cachedArtists).get()) a.id: a.name + }; + final albumTitle = { + for (final a in await db.select(db.cachedAlbums).get()) a.id: a.title + }; + + final refs = [ + for (final t in meta) + t.toRef( + artistName: artistName[t.artistId] ?? '', + albumTitle: albumTitle[t.albumId] ?? '', + ) + ]; + refs.shuffle(Random()); + return refs.length > limit ? refs.sublist(0, limit) : refs; + } +} + +final shuffleSourceProvider = + Provider((ref) => ShuffleSource(ref)); diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index 968ed772..c736a31f 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../api/errors.dart'; +import '../cache/shuffle_source.dart'; import '../cache/tile_providers.dart'; +import '../player/player_provider.dart'; import '../models/playlist.dart'; import '../models/system_playlists_status.dart'; import '../models/track.dart'; @@ -41,7 +43,28 @@ class HomeScreen extends ConsumerWidget { backgroundColor: fs.obsidian, elevation: 0, title: Text('Minstrel', style: TextStyle(color: fs.parchment)), - actions: const [MainAppBarActions(currentRoute: '/home')], + actions: [ + IconButton( + key: const Key('shuffle_all_button'), + tooltip: 'Shuffle all', + icon: Icon(Icons.shuffle, color: fs.parchment), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final refs = + await ref.read(shuffleSourceProvider).tracks(); + if (refs.isEmpty) { + messenger.showSnackBar(const SnackBar( + content: Text('Nothing to shuffle yet'), + )); + return; + } + await ref + .read(playerActionsProvider) + .playTracks(refs, shuffle: true); + }, + ), + const MainAppBarActions(currentRoute: '/home'), + ], ), body: SafeArea( child: index.when( diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart index 16cfaef0..cf439056 100644 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ b/flutter_client/lib/playlists/widgets/playlist_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../cache/offline_provider.dart'; import '../../library/widgets/play_circle_button.dart'; import '../../models/playlist.dart'; import '../../models/track.dart'; @@ -23,7 +24,12 @@ class PlaylistCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - final hasTracks = playlist.trackCount > 0; + // #427 S4: refreshable system playlists need the live build/ + // shuffle endpoints — unavailable offline. Disable their play + // (use Shuffle all instead). User playlists play from cache. + final offline = ref.watch(offlineProvider); + final hasTracks = playlist.trackCount > 0 && + !(offline && playlist.refreshable); return SizedBox( width: 176, child: Material( diff --git a/flutter_client/test/playlists/widgets/playlist_card_test.dart b/flutter_client/test/playlists/widgets/playlist_card_test.dart index 228a9707..69086907 100644 --- a/flutter_client/test/playlists/widgets/playlist_card_test.dart +++ b/flutter_client/test/playlists/widgets/playlist_card_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:minstrel/models/playlist.dart'; @@ -35,44 +36,48 @@ const _forYou = Playlist( void main() { testWidgets('renders name and no badge for user playlist', (tester) async { - await tester.pumpWidget(MaterialApp( + await tester.pumpWidget(ProviderScope( + child: MaterialApp( theme: buildThemeData(), home: const Scaffold( body: PlaylistCard(playlist: _userPlaylist), ), - )); + ))); expect(find.text('Road trip'), findsOneWidget); expect(find.text('for you'), findsNothing); }); testWidgets('renders system badge for system playlist', (tester) async { - await tester.pumpWidget(MaterialApp( + await tester.pumpWidget(ProviderScope( + child: MaterialApp( theme: buildThemeData(), home: const Scaffold( body: PlaylistCard(playlist: _forYou), ), - )); + ))); expect(find.text('For You'), findsOneWidget); expect(find.text('for you'), findsOneWidget); }); testWidgets('shows refresh kebab on system playlists', (tester) async { - await tester.pumpWidget(MaterialApp( + await tester.pumpWidget(ProviderScope( + child: MaterialApp( theme: buildThemeData(), home: const Scaffold( body: PlaylistCard(playlist: _forYou), ), - )); + ))); expect(find.byIcon(Icons.more_vert), findsOneWidget); }); testWidgets('no refresh kebab on user playlists', (tester) async { - await tester.pumpWidget(MaterialApp( + await tester.pumpWidget(ProviderScope( + child: MaterialApp( theme: buildThemeData(), home: const Scaffold( body: PlaylistCard(playlist: _userPlaylist), ), - )); + ))); expect(find.byIcon(Icons.more_vert), findsNothing); }); } diff --git a/internal/api/api.go b/internal/api/api.go index 52878972..4f724493 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -74,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) authed.Get("/albums/{id}", h.handleGetAlbum) authed.Get("/albums/{id}/cover", h.handleGetCover) + authed.Get("/library/shuffle", h.handleLibraryShuffle) authed.Get("/library/albums", h.handleListLibraryAlbums) authed.Get("/library/sync", h.handleLibrarySync) authed.Get("/tracks/{id}", h.handleGetTrack) diff --git a/internal/api/library.go b/internal/api/library.go index 206c5549..1bfc7523 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -3,6 +3,7 @@ package api import ( "errors" "net/http" + "strconv" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -158,6 +159,43 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, out) } +// handleLibraryShuffle implements GET /api/library/shuffle?limit=N — +// the online source for the client's always-present "Shuffle all" +// (#427 S4). N random tracks across the whole library, per-user +// quarantine filtered. limit defaults to 100, clamped to 1..500. +// Offline, the client shuffles its local cache instead and never +// calls this. +func (h *handlers) handleLibraryShuffle(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + limit := 100 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + limit = n + } + } + if limit < 1 { + limit = 1 + } + if limit > 500 { + limit = 500 + } + rows, err := dbq.New(h.pool).ListRandomTracksForUser(r.Context(), + dbq.ListRandomTracksForUserParams{UserID: user.ID, Limit: int32(limit)}) + if err != nil { + h.logger.Error("api: library shuffle", "err", err) + writeErr(w, apierror.InternalMsg("shuffle failed", err)) + return + } + out := make([]TrackRef, 0, len(rows)) + for _, row := range rows { + out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) + } + writeJSON(w, http.StatusOK, out) +} + // handleListArtists implements GET /api/artists with two sort modes // (alpha|newest) and enveloped pagination. The alpha branch uses // ListArtistsAlphaWithCovers to ship cover_url + album_count in a diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 2f522c3a..7e21a3f3 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -255,6 +255,73 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra return items, nil } +const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +ORDER BY random() +LIMIT $2 +` + +type ListRandomTracksForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListRandomTracksForUserRow struct { + Track Track + AlbumTitle string + ArtistName string +} + +// #427 S4: backing query for GET /api/library/shuffle — the online +// source for "Shuffle all". N random tracks across the whole +// library, per-user-quarantine filtered. $1 user_id, $2 limit. +func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTracksForUserParams) ([]ListRandomTracksForUserRow, error) { + rows, err := q.db.Query(ctx, listRandomTracksForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRandomTracksForUserRow + for rows.Next() { + var i ListRandomTracksForUserRow + if err := rows.Scan( + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.AlbumTitle, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listTracksByAlbum = `-- name: ListTracksByAlbum :many SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE album_id = $1 diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index 1e661ce0..4019b684 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -81,6 +81,23 @@ WHERE t.artist_id = $1 ORDER BY albums.release_date NULLS LAST, albums.sort_title, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id; +-- name: ListRandomTracksForUser :many +-- #427 S4: backing query for GET /api/library/shuffle — the online +-- source for "Shuffle all". N random tracks across the whole +-- library, per-user-quarantine filtered. $1 user_id, $2 limit. +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +ORDER BY random() +LIMIT $2; + -- name: CountTracksByArtist :one -- Used by request-progress reporting to count tracks ingested under a -- matched artist (sum across all the artist's albums) while a request