Files
minstrel/flutter_client/lib/library/library_providers.dart
T
bvandeusen 22bd06a578 feat(flutter): drift-first homeProvider (#357 deferred follow-up)
Home screen is the first surface on app open; without a local cache
the cold-start blocks on /api/home, which dominates felt latency on
slow or remote connections. This commit caches the last successful
HomeData as a single-row JSON blob in drift, so subsequent app opens
yield content immediately and revalidate in the background.

Schema:
  - New CachedHomeSnapshot table (single row: id=1, json TEXT,
    updated_at). schemaVersion bumped 2 → 3 with a forward migration
    that calls m.createTable(cachedHomeSnapshot). Codegen regenerated
    via build_runner; the *.g.dart files are gitignored and rebuilt
    by the CI Codegen step.

Provider rewrite:
  - homeProvider: FutureProvider<HomeData> → StreamProvider<HomeData>
    using the existing cacheFirst<CachedHomeSnapshotData, HomeData>
    pattern (alwaysRefresh: true for SWR). On cold cache the first
    /api/home fetch populates the row. On warm cache the cached
    HomeData is yielded immediately and a background REST fetch
    overwrites the row, which drift's watch() picks up.

  - Encoder helpers (_albumToJson / _artistToJson / _trackToJson) so
    HomeData survives the JSON round-trip into and out of drift.
    Field names match the server's /api/home wire shape exactly so
    HomeData.fromJson handles both fresh server responses and cached
    drift rows.

Callers untouched: home_screen.dart's ref.watch + ref.refresh +
metadata_prefetcher's ref.listen all keep working with the
StreamProvider shape (AsyncValue<HomeData> stays the surface type).

Test fix: 4 homeProvider.overrideWith sites in home_screen_test.dart
switched from `(ref) async => _emptyHome` (FutureProvider form) to
`(ref) => Stream.value(_emptyHome)` (StreamProvider form).

For #357. Completes the user-visible deferred follow-up. Remaining
deferred items: library_changes server-side retention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:40:43 -04:00

403 lines
15 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/client.dart';
import '../api/endpoints/library.dart';
import '../auth/auth_provider.dart';
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.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'),
on401: () async => ref.read(authControllerProvider.notifier).clearSession(),
);
});
final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
return LibraryApi(await ref.watch(dioProvider.future));
});
/// Drift-first home data (#357 follow-up). The home screen is the first
/// view the user sees on app open; without a local cache the cold-start
/// blocks on the /api/home round-trip, which is the dominant felt-
/// latency on slow / remote connections.
///
/// Cache layout: a single row in `cached_home_snapshot` storing the
/// last successful HomeData as JSON. On stream subscription:
///
/// - If a row exists, yield it immediately (parsed from JSON) and
/// kick off a background revalidation against /api/home (SWR).
/// The drift watch() re-emits when the new row is written.
/// - If no row exists yet (cold cache), fetch /api/home synchronously,
/// upsert into drift, and emit. Subsequent emissions come from the
/// drift watch as background revalidations land.
///
/// The HomeData JSON encodes back to the same wire shape /api/home
/// emits, so the existing HomeData.fromJson handles both sources.
final homeProvider = StreamProvider<HomeData>((ref) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedHomeSnapshotData, HomeData>(
driftStream: db.select(db.cachedHomeSnapshot).watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getHome();
await db.into(db.cachedHomeSnapshot).insertOnConflictUpdate(
CachedHomeSnapshotCompanion.insert(
json: _encodeHomeData(fresh),
updatedAt: drift.Value(DateTime.now()),
),
);
},
toResult: (rows) => rows.isEmpty
? const HomeData(
recentlyAddedAlbums: [],
rediscoverAlbums: [],
rediscoverArtists: [],
mostPlayedTracks: [],
lastPlayedArtists: [],
)
: HomeData.fromJson(jsonDecode(rows.first.json) as Map<String, dynamic>),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: when cached snapshot exists, still hit /api/home in the
// background so the user sees the freshest curation. The cache
// mostly serves the first frame, not the canonical state.
alwaysRefresh: true,
tag: 'home',
);
});
/// Encodes HomeData back to the wire-format JSON shape /api/home
/// emits, so HomeData.fromJson can round-trip through the drift cache.
String _encodeHomeData(HomeData h) => jsonEncode({
'recently_added_albums': h.recentlyAddedAlbums.map(_albumToJson).toList(),
'rediscover_albums': h.rediscoverAlbums.map(_albumToJson).toList(),
'rediscover_artists': h.rediscoverArtists.map(_artistToJson).toList(),
'most_played_tracks': h.mostPlayedTracks.map(_trackToJson).toList(),
'last_played_artists': h.lastPlayedArtists.map(_artistToJson).toList(),
});
Map<String, dynamic> _albumToJson(AlbumRef a) => {
'id': a.id,
'title': a.title,
'sort_title': a.sortTitle,
'artist_id': a.artistId,
'artist_name': a.artistName,
'year': a.year,
'track_count': a.trackCount,
'duration_sec': a.durationSec,
'cover_url': a.coverUrl,
};
Map<String, dynamic> _artistToJson(ArtistRef a) => {
'id': a.id,
'name': a.name,
'sort_name': a.sortName,
'album_count': a.albumCount,
'cover_url': a.coverUrl,
};
Map<String, dynamic> _trackToJson(TrackRef t) => {
'id': t.id,
'title': t.title,
'album_id': t.albumId,
'album_title': t.albumTitle,
'artist_id': t.artistId,
'artist_name': t.artistName,
'track_number': t.trackNumber,
'disc_number': t.discNumber,
'duration_sec': t.durationSec,
'stream_url': t.streamUrl,
};
/// Drift-first per #357 plan C. Watches cached_artists for the row;
/// when empty + online, fetches via REST + populates drift, which
/// re-emits via watch().
final artistProvider =
StreamProvider.family<ArtistRef, String>((ref, id) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedArtist, ArtistRef>(
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtist(id);
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
},
toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '')
: rows.first.toRef(),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// No alwaysRefresh: artist rows don't change frequently, and the
// metadata prefetcher creates many subscriptions in parallel —
// each silently re-fetching once would saturate the request
// pipeline behind the user's actual playback request.
tag: 'artist($id)',
);
});
final artistAlbumsProvider =
StreamProvider.family<List<AlbumRef>, 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<drift.TypedResult, List<AlbumRef>>(
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)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: re-fetch the full album list on every visit. Without this,
// a previously-incomplete drift cache (e.g. user had only opened
// one album by this artist, so cachedAlbums had just that row)
// would render forever as a partial list. The prefetcher only
// warms artistProvider (single row), so this provider isn't
// mass-instantiated and the storm risk that motivated dropping
// alwaysRefresh elsewhere doesn't apply here.
alwaysRefresh: true,
tag: 'artistAlbums($artistId)',
);
});
final artistTracksProvider =
StreamProvider.family<List<TrackRef>, 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<drift.TypedResult, List<TrackRef>>(
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)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
tag: 'artistTracks($artistId)',
);
});
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
/// for reactive updates over both rows.
final albumProvider = StreamProvider.family<
({AlbumRef album, List<TrackRef> 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)),
]);
// Once-per-subscription guard so we don't re-fetch in a loop if the
// server genuinely returns zero tracks (or if the fetch fails).
var fetchAttempted = false;
// (revalidated flag removed; see SWR note below the yield.)
Future<bool> isOnline() async {
try {
return await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
} catch (_) {
return true;
}
}
// Cold-cache fetch: pulls the album + its tracks + any unique
// artists referenced and writes all three tables in one batch.
// Returns true on success (drift watch will re-emit), false on
// failure so the caller can yield an empty result.
Future<bool> fetchAndPopulate() async {
try {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api
.getAlbum(albumId)
.timeout(const Duration(seconds: 10));
// Collect every artist mentioned by the album + its tracks so
// the JOINs that drive both the album header and the track rows
// have something to bind to. Without this, drift returns null
// for the artist row and artistName surfaces empty — visible
// as a missing artist line in the mini player and row list.
final artists = <String, ArtistRef>{};
if (fresh.album.artistId.isNotEmpty &&
fresh.album.artistName.isNotEmpty) {
artists[fresh.album.artistId] = ArtistRef(
id: fresh.album.artistId,
name: fresh.album.artistName,
);
}
for (final t in fresh.tracks) {
if (t.artistId.isNotEmpty &&
t.artistName.isNotEmpty &&
!artists.containsKey(t.artistId)) {
artists[t.artistId] = ArtistRef(
id: t.artistId,
name: t.artistName,
);
}
}
await db.batch((b) {
if (artists.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedArtists, artists.values.map((a) => a.toDrift()).toList());
}
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
b.insertAllOnConflictUpdate(db.cachedTracks,
fresh.tracks.map((t) => t.toDrift()).toList());
});
return true;
} catch (e, st) {
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
return false;
}
}
await for (final albumRows in albumQuery.watch()) {
// Case 1: no album row at all → cold-fetch.
if (albumRows.isEmpty) {
if (fetchAttempted) {
// Already tried and got nothing back.
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
continue;
}
fetchAttempted = true;
if (!await isOnline()) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
continue;
}
final ok = await fetchAndPopulate();
if (!ok) {
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
}
// On success, drift watch re-emits with rows; loop continues.
continue;
}
final albumRow = albumRows.first;
final album = albumRow.readTable(db.cachedAlbums).toRef(
artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '',
);
final trackRows = await tracksQuery.get();
// Case 2: album row exists but no tracks. This happens when an
// upstream provider (artistAlbumsProvider, sync, etc.) populated
// album rows without their track lists. Trigger the same
// fetchAndPopulate so the album becomes complete; drift watch
// re-emits and we land in the populated branch on the next pass.
if (trackRows.isEmpty && !fetchAttempted) {
fetchAttempted = true;
if (await isOnline()) {
final ok = await fetchAndPopulate();
if (ok) continue; // wait for watch re-emit
}
// Fall through and yield with the album + empty tracks if the
// fetch failed or we're offline.
}
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();
// Note: NO SWR here on purpose. Prior code kicked a background
// refresh on every first cache hit, which combined with the
// metadata prefetcher meant every prewarmed album id triggered
// an extra fetch even when drift was already populated. Tracks
// and album metadata don't change on the same timescale as
// playlists; a stale read is fine until the user invalidates
// (pull-to-refresh) or the album is genuinely re-fetched.
yield (album: album, tracks: tracks);
}
});