From 3e267918b73a10353a749948c34d074aa02adc22 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 17:26:57 -0400 Subject: [PATCH] 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. --- flutter_client/lib/api/endpoints/library.dart | 71 ++++++++ .../lib/library/library_providers.dart | 59 ++++++ .../test/api/endpoints/library_test.dart | 170 ++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 flutter_client/lib/api/endpoints/library.dart create mode 100644 flutter_client/lib/library/library_providers.dart create mode 100644 flutter_client/test/api/endpoints/library_test.dart diff --git a/flutter_client/lib/api/endpoints/library.dart b/flutter_client/lib/api/endpoints/library.dart new file mode 100644 index 00000000..8fab0c23 --- /dev/null +++ b/flutter_client/lib/api/endpoints/library.dart @@ -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 getHome() async { + final r = await _dio.get>('/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 getArtist(String id) async { + final r = await _dio.get>('/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> getArtistAlbums(String id) async { + final r = await _dio.get>('/api/artists/$id'); + final raw = (r.data?['albums'] as List?) ?? const []; + return raw + .map((e) => AlbumRef.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 { + final r = await _dio.get>('/api/artists/$id/tracks'); + final raw = r.data ?? const []; + return raw + .map((e) => TrackRef.fromJson((e as Map).cast())) + .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 tracks})> getAlbum(String id) async { + final r = await _dio.get>('/api/albums/$id'); + final body = r.data ?? const {}; + final tracks = ((body['tracks'] as List?) ?? const []) + .map((e) => TrackRef.fromJson((e as Map).cast())) + .toList(growable: false); + return (album: AlbumRef.fromJson(body), tracks: tracks); + } +} diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart new file mode 100644 index 00000000..d47707f5 --- /dev/null +++ b/flutter_client/lib/library/library_providers.dart @@ -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((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((ref) async { + return LibraryApi(await ref.watch(dioProvider.future)); +}); + +final homeProvider = FutureProvider((ref) async { + return (await ref.watch(libraryApiProvider.future)).getHome(); +}); + +final artistProvider = + FutureProvider.family((ref, id) async { + return (await ref.watch(libraryApiProvider.future)).getArtist(id); +}); + +final artistAlbumsProvider = + FutureProvider.family, String>((ref, id) async { + return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id); +}); + +final artistTracksProvider = + FutureProvider.family, String>((ref, id) async { + return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id); +}); + +final albumProvider = + FutureProvider.family<({AlbumRef album, List tracks}), String>( + (ref, id) async { + return (await ref.watch(libraryApiProvider.future)).getAlbum(id); + }, +); diff --git a/flutter_client/test/api/endpoints/library_test.dart b/flutter_client/test/api/endpoints/library_test.dart new file mode 100644 index 00000000..e1d36831 --- /dev/null +++ b/flutter_client/test/api/endpoints/library_test.dart @@ -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 fetch( + RequestOptions options, + Stream? requestStream, + Future? 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({ + '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({ + '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({ + '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(>[ + { + '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({ + '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); + }); + }); +}