feat(flutter/library): API endpoints + Riverpod providers
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.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/album.dart';
|
||||
import '../../models/artist.dart';
|
||||
import '../../models/home_data.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/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 {});
|
||||
}
|
||||
|
||||
/// 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/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/client.dart';
|
||||
import '../api/endpoints/library.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../models/album.dart';
|
||||
import '../models/artist.dart';
|
||||
import '../models/home_data.dart';
|
||||
import '../models/track.dart';
|
||||
|
||||
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
|
||||
/// to the secure-storage `session_token` read — every other endpoint
|
||||
/// awaits `dioProvider.future` rather than constructing its own dio.
|
||||
///
|
||||
/// Riverpod will invalidate any FutureProvider that watches this when
|
||||
/// the server URL changes; auth state changes don't need to invalidate
|
||||
/// the provider because the resolver re-reads the token on every request.
|
||||
final dioProvider = FutureProvider<Dio>((ref) async {
|
||||
final url = await ref.watch(serverUrlProvider.future);
|
||||
if (url == null || url.isEmpty) {
|
||||
throw StateError('no server URL set');
|
||||
}
|
||||
final storage = ref.watch(secureStorageProvider);
|
||||
return ApiClient.buildDio(
|
||||
baseUrl: url,
|
||||
tokenResolver: () async => storage.read(key: 'session_token'),
|
||||
);
|
||||
});
|
||||
|
||||
final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
|
||||
return LibraryApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
final homeProvider = FutureProvider<HomeData>((ref) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getHome();
|
||||
});
|
||||
|
||||
final artistProvider =
|
||||
FutureProvider.family<ArtistRef, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtist(id);
|
||||
});
|
||||
|
||||
final artistAlbumsProvider =
|
||||
FutureProvider.family<List<AlbumRef>, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id);
|
||||
});
|
||||
|
||||
final artistTracksProvider =
|
||||
FutureProvider.family<List<TrackRef>, String>((ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id);
|
||||
});
|
||||
|
||||
final albumProvider =
|
||||
FutureProvider.family<({AlbumRef album, List<TrackRef> tracks}), String>(
|
||||
(ref, id) async {
|
||||
return (await ref.watch(libraryApiProvider.future)).getAlbum(id);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/api/endpoints/library.dart';
|
||||
|
||||
/// Stub adapter that returns a fixed JSON response for any request. The
|
||||
/// body argument can be either a Map or a List (the artist-tracks endpoint
|
||||
/// emits a top-level JSON array). Per-test wiring keeps the routing trivial.
|
||||
class _StubAdapter implements HttpClientAdapter {
|
||||
_StubAdapter(this._body);
|
||||
final Object _body;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
return ResponseBody.fromString(
|
||||
jsonEncode(_body),
|
||||
200,
|
||||
headers: const {
|
||||
Headers.contentTypeHeader: ['application/json'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
Dio _dioWith(Object body) {
|
||||
final d = Dio(BaseOptions(baseUrl: 'http://example.test'));
|
||||
d.httpClientAdapter = _StubAdapter(body);
|
||||
return d;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('LibraryApi', () {
|
||||
test('getAlbum parses album fields and tracks list', () async {
|
||||
final api = LibraryApi(_dioWith(<String, dynamic>{
|
||||
'id': 'al-1',
|
||||
'title': 'Geogaddi',
|
||||
'sort_title': 'geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
'year': 2002,
|
||||
'track_count': 1,
|
||||
'duration_sec': 312,
|
||||
'cover_url': '/api/albums/al-1/cover',
|
||||
'tracks': [
|
||||
{
|
||||
'id': 't-1',
|
||||
'title': 'Music Is Math',
|
||||
'album_id': 'al-1',
|
||||
'album_title': 'Geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
'duration_sec': 312,
|
||||
'track_number': 4,
|
||||
'stream_url': '/api/tracks/t-1/stream',
|
||||
},
|
||||
],
|
||||
}));
|
||||
final r = await api.getAlbum('al-1');
|
||||
expect(r.album.id, 'al-1');
|
||||
expect(r.album.title, 'Geogaddi');
|
||||
expect(r.album.artistName, 'Boards of Canada');
|
||||
expect(r.album.year, 2002);
|
||||
expect(r.tracks, hasLength(1));
|
||||
expect(r.tracks.single.title, 'Music Is Math');
|
||||
expect(r.tracks.single.durationSec, 312);
|
||||
expect(r.tracks.single.trackNumber, 4);
|
||||
});
|
||||
|
||||
test('getArtist parses ArtistDetail body as ArtistRef', () async {
|
||||
final api = LibraryApi(_dioWith(<String, dynamic>{
|
||||
'id': 'art-1',
|
||||
'name': 'Boards of Canada',
|
||||
'sort_name': 'boards of canada',
|
||||
'album_count': 5,
|
||||
'cover_url': '/api/albums/al-1/cover',
|
||||
'albums': const [],
|
||||
}));
|
||||
final a = await api.getArtist('art-1');
|
||||
expect(a.id, 'art-1');
|
||||
expect(a.name, 'Boards of Canada');
|
||||
expect(a.albumCount, 5);
|
||||
});
|
||||
|
||||
test('getArtistAlbums extracts the albums array', () async {
|
||||
final api = LibraryApi(_dioWith(<String, dynamic>{
|
||||
'id': 'art-1',
|
||||
'name': 'Boards of Canada',
|
||||
'albums': [
|
||||
{
|
||||
'id': 'al-1',
|
||||
'title': 'Geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
},
|
||||
{
|
||||
'id': 'al-2',
|
||||
'title': 'The Campfire Headphase',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
},
|
||||
],
|
||||
}));
|
||||
final albums = await api.getArtistAlbums('art-1');
|
||||
expect(albums, hasLength(2));
|
||||
expect(albums.first.id, 'al-1');
|
||||
expect(albums.last.title, 'The Campfire Headphase');
|
||||
});
|
||||
|
||||
test('getArtistTracks parses a top-level JSON array', () async {
|
||||
// Server emits a bare []TrackRef, NOT {"tracks": [...]}.
|
||||
final api = LibraryApi(_dioWith(<Map<String, dynamic>>[
|
||||
{
|
||||
'id': 't-1',
|
||||
'title': 'Music Is Math',
|
||||
'album_id': 'al-1',
|
||||
'album_title': 'Geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
'duration_sec': 312,
|
||||
},
|
||||
{
|
||||
'id': 't-2',
|
||||
'title': 'Dawn Chorus',
|
||||
'album_id': 'al-1',
|
||||
'album_title': 'Geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
'duration_sec': 168,
|
||||
},
|
||||
]));
|
||||
final tracks = await api.getArtistTracks('art-1');
|
||||
expect(tracks, hasLength(2));
|
||||
expect(tracks.first.id, 't-1');
|
||||
expect(tracks.last.durationSec, 168);
|
||||
});
|
||||
|
||||
test('getHome parses HomePayload sections', () async {
|
||||
final api = LibraryApi(_dioWith(<String, dynamic>{
|
||||
'recently_added_albums': [
|
||||
{
|
||||
'id': 'al-1',
|
||||
'title': 'Geogaddi',
|
||||
'artist_id': 'art-1',
|
||||
'artist_name': 'Boards of Canada',
|
||||
},
|
||||
],
|
||||
'rediscover_albums': const [],
|
||||
'rediscover_artists': const [],
|
||||
'most_played_tracks': const [],
|
||||
'last_played_artists': [
|
||||
{'id': 'art-1', 'name': 'Boards of Canada'},
|
||||
],
|
||||
}));
|
||||
final home = await api.getHome();
|
||||
expect(home.recentlyAddedAlbums.single.title, 'Geogaddi');
|
||||
expect(home.lastPlayedArtists.single.name, 'Boards of Canada');
|
||||
expect(home.rediscoverAlbums, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user