From fc83c7b5149f64710ca794dde369aa622fef2d70 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 10 May 2026 11:19:47 -0400 Subject: [PATCH] feat(flutter/cache): migrate artistAlbums/artistTracks/album to drift-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three providers all become StreamProviders driven by drift watch(): - artistAlbumsProvider: cacheFirst over the join of cached_albums + cached_artists for artist_name on each row. - artistTracksProvider: cacheFirst over the join of cached_tracks + cached_artists + cached_albums for snapshot fields. - albumProvider: composite ({album, tracks}) shape, so uses async* with two queries (album + tracks) for cleaner reactive behavior. Cold-cache fallback inlined; populates both album and tracks tables in one batch. .future call site in artist_detail_screen.dart left as-is — StreamProvider.future returns the next emission with the same signature, so no consumer change needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/library/library_providers.dart | 137 ++++++++++++++++-- 1 file changed, 127 insertions(+), 10 deletions(-) diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index b18cf932..be1a2c5e 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -66,18 +66,135 @@ final artistProvider = }); final artistAlbumsProvider = - FutureProvider.family, String>((ref, id) async { - return (await ref.watch(libraryApiProvider.future)).getArtistAlbums(id); + StreamProvider.family, String>((ref, artistId) { + final db = ref.watch(appDbProvider); + // Join cached_albums + cached_artists to fill artist_name on each row. + final query = db.select(db.cachedAlbums).join([ + drift.leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), + ])..where(db.cachedAlbums.artistId.equals(artistId)); + + return cacheFirst>( + driftStream: query.watch(), + fetchAndPopulate: () async { + final api = await ref.read(libraryApiProvider.future); + final fresh = await api.getArtistAlbums(artistId); + await db.batch((b) { + b.insertAllOnConflictUpdate( + db.cachedAlbums, fresh.map((a) => a.toDrift()).toList()); + }); + }, + toResult: (rows) => rows.map((r) { + final album = r.readTable(db.cachedAlbums); + final artist = r.readTableOrNull(db.cachedArtists); + return album.toRef(artistName: artist?.name ?? ''); + }).toList(), + isOnline: () async => + (await ref.read(connectivityProvider.future)), + ); }); final artistTracksProvider = - FutureProvider.family, String>((ref, id) async { - return (await ref.watch(libraryApiProvider.future)).getArtistTracks(id); + StreamProvider.family, String>((ref, artistId) { + final db = ref.watch(appDbProvider); + final query = db.select(db.cachedTracks).join([ + drift.leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), + drift.leftOuterJoin(db.cachedAlbums, + db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)), + ])..where(db.cachedTracks.artistId.equals(artistId)); + + return cacheFirst>( + driftStream: query.watch(), + fetchAndPopulate: () async { + final api = await ref.read(libraryApiProvider.future); + final fresh = await api.getArtistTracks(artistId); + await db.batch((b) { + b.insertAllOnConflictUpdate( + db.cachedTracks, fresh.map((t) => t.toDrift()).toList()); + }); + }, + toResult: (rows) => rows.map((r) { + final track = r.readTable(db.cachedTracks); + final artist = r.readTableOrNull(db.cachedArtists); + final album = r.readTableOrNull(db.cachedAlbums); + return track.toRef( + artistName: artist?.name ?? '', + albumTitle: album?.title ?? '', + ); + }).toList(), + isOnline: () async => + (await ref.read(connectivityProvider.future)), + ); }); -final albumProvider = - FutureProvider.family<({AlbumRef album, List tracks}), String>( - (ref, id) async { - return (await ref.watch(libraryApiProvider.future)).getAlbum(id); - }, -); +/// Composite shape (album + tracks) — uses async* + Stream.combineLatest +/// for reactive updates over both rows. +final albumProvider = StreamProvider.family< + ({AlbumRef album, List tracks}), String>((ref, albumId) async* { + final db = ref.watch(appDbProvider); + + final albumQuery = (db.select(db.cachedAlbums) + ..where((t) => t.id.equals(albumId))) + .join([ + drift.leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)), + ]); + + final tracksQuery = (db.select(db.cachedTracks) + ..where((t) => t.albumId.equals(albumId)) + ..orderBy([ + (t) => drift.OrderingTerm.asc(t.discNumber), + (t) => drift.OrderingTerm.asc(t.trackNumber), + ])) + .join([ + drift.leftOuterJoin(db.cachedArtists, + db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)), + ]); + + await for (final albumRows in albumQuery.watch()) { + if (albumRows.isEmpty) { + // Cold cache fallback + if (await ref.read(connectivityProvider.future)) { + try { + final api = await ref.read(libraryApiProvider.future); + final fresh = await api.getAlbum(albumId); + await db.batch((b) { + b.insertOnConflictUpdate(db.cachedAlbums, fresh.album.toDrift()); + b.insertAllOnConflictUpdate(db.cachedTracks, + fresh.tracks.map((t) => t.toDrift()).toList()); + }); + // watch() re-emits with the populated rows; loop continues. + } catch (_) { + yield ( + album: const AlbumRef(id: '', title: '', artistId: ''), + tracks: const [], + ); + } + } else { + yield ( + album: const AlbumRef(id: '', title: '', artistId: ''), + tracks: const [], + ); + } + continue; + } + + final albumRow = albumRows.first; + final album = albumRow.readTable(db.cachedAlbums).toRef( + artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '', + ); + + final trackRows = await tracksQuery.get(); + final tracks = trackRows.map((r) { + final track = r.readTable(db.cachedTracks); + final artist = r.readTableOrNull(db.cachedArtists); + return track.toRef( + artistName: artist?.name ?? '', + albumTitle: album.title, + ); + }).toList(); + + yield (album: album, tracks: tracks); + } +});