a5e2abb8c4
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>
122 lines
5.4 KiB
Dart
122 lines
5.4 KiB
Dart
import 'package:dio/dio.dart';
|
||
|
||
import '../../models/album.dart';
|
||
import '../../models/artist.dart';
|
||
import '../../models/home_data.dart';
|
||
import '../../models/home_index.dart';
|
||
import '../../models/track.dart';
|
||
|
||
/// LibraryApi wraps the server's native /api/* library surface.
|
||
///
|
||
/// Response shapes (verified against internal/api/library.go,
|
||
/// internal/api/library_albums.go, internal/api/home.go):
|
||
///
|
||
/// GET /api/home → HomePayload (flat sections)
|
||
/// GET /api/artists/{id} → ArtistDetail (ArtistRef fields + "albums")
|
||
/// GET /api/artists/{id}/tracks → flat []TrackRef array (NOT enveloped)
|
||
/// GET /api/albums/{id} → AlbumDetail (AlbumRef fields + "tracks")
|
||
///
|
||
/// The artist-tracks endpoint deviates from the plan-text starter: the
|
||
/// server emits a bare JSON array, not `{"tracks": [...]}`. We parse the
|
||
/// top-level response as `List` accordingly.
|
||
class LibraryApi {
|
||
LibraryApi(this._dio);
|
||
final Dio _dio;
|
||
|
||
Future<HomeData> getHome() async {
|
||
final r = await _dio.get<Map<String, dynamic>>('/api/home');
|
||
return HomeData.fromJson(r.data ?? const {});
|
||
}
|
||
|
||
/// GET /api/home/index — per-item rendering variant. Returns just IDs
|
||
/// per section; client hydrates each tile via the per-entity
|
||
/// endpoints. ~10× smaller than /api/home on populated libraries so
|
||
/// the cold-visit round-trip is correspondingly short.
|
||
Future<HomeIndex> getHomeIndex() async {
|
||
final r = await _dio.get<Map<String, dynamic>>('/api/home/index');
|
||
return HomeIndex.fromJson(r.data ?? const {});
|
||
}
|
||
|
||
/// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by
|
||
/// the HydrationQueue to populate cached_tracks on a per-tile miss
|
||
/// — the existing endpoint already joins album + artist so the
|
||
/// response carries everything TrackRef needs.
|
||
Future<TrackRef> getTrack(String id) async {
|
||
final r = await _dio.get<Map<String, dynamic>>('/api/tracks/$id');
|
||
return TrackRef.fromJson(r.data ?? const {});
|
||
}
|
||
|
||
/// GET /api/artists/{id}. Server returns ArtistDetail which embeds
|
||
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
|
||
/// it cares about, so passing the whole body is correct.
|
||
Future<ArtistRef> getArtist(String id) async {
|
||
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
|
||
return ArtistRef.fromJson(r.data ?? const {});
|
||
}
|
||
|
||
/// GET /api/artists/{id} — full response with the ArtistRef AND the
|
||
/// embedded album list, both parsed. Single round-trip variant used
|
||
/// by CacheFiller and other callers that want to populate both
|
||
/// cached_artists and cached_albums in one shot. The existing
|
||
/// getArtist / getArtistAlbums keep working for callers that only
|
||
/// need one half — they hit the same URL but the per-id Riverpod
|
||
/// caching layer dedupes.
|
||
Future<({ArtistRef artist, List<AlbumRef> albums})> getArtistDetail(
|
||
String id) async {
|
||
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
|
||
final body = r.data ?? const <String, dynamic>{};
|
||
final artist = ArtistRef.fromJson(body);
|
||
final albums = ((body['albums'] as List?) ?? const [])
|
||
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
|
||
.toList(growable: false);
|
||
return (artist: artist, albums: albums);
|
||
}
|
||
|
||
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
|
||
/// that need both the artist and its albums should issue two provider
|
||
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
|
||
/// underlying URL and dio's response is not memoized here, but the
|
||
/// Riverpod layer caches per-id so cost stays at one round-trip.
|
||
Future<List<AlbumRef>> getArtistAlbums(String id) async {
|
||
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
|
||
final raw = (r.data?['albums'] as List?) ?? const [];
|
||
return raw
|
||
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
|
||
.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<List<TrackRef>> shuffle({int limit = 100}) async {
|
||
final r = await _dio.get<List<dynamic>>(
|
||
'/api/library/shuffle',
|
||
queryParameters: {'limit': limit},
|
||
);
|
||
final raw = r.data ?? const [];
|
||
return raw
|
||
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||
.toList(growable: false);
|
||
}
|
||
|
||
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
|
||
/// type the response as `List<dynamic>` rather than a Map envelope.
|
||
Future<List<TrackRef>> getArtistTracks(String id) async {
|
||
final r = await _dio.get<List<dynamic>>('/api/artists/$id/tracks');
|
||
final raw = r.data ?? const [];
|
||
return raw
|
||
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||
.toList(growable: false);
|
||
}
|
||
|
||
/// GET /api/albums/{id}. Returns the album ref alongside its track list
|
||
/// in a single record so screens can render both without a second fetch.
|
||
Future<({AlbumRef album, List<TrackRef> tracks})> getAlbum(String id) async {
|
||
final r = await _dio.get<Map<String, dynamic>>('/api/albums/$id');
|
||
final body = r.data ?? const <String, dynamic>{};
|
||
final tracks = ((body['tracks'] as List?) ?? const [])
|
||
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
|
||
.toList(growable: false);
|
||
return (album: AlbumRef.fromJson(body), tracks: tracks);
|
||
}
|
||
}
|