Files
minstrel/flutter_client/lib/library/library_providers.dart
T
bvandeusen 4ede37d9ad fix(flutter): stop the cache feedback loop — playback no longer competes
The prefetcher + alwaysRefresh combination was creating a feedback
loop visible in the logs as repeated `metadataPrefetcher: warming N
albums` cycles, each kicking N parallel getAlbum fetches that then
triggered drift writes that triggered re-emits that re-ran the
prefetcher. Tap-to-play was queueing behind 14+ in-flight cache
fetches.

Three structural fixes:

1. Prefetcher hard-dedupes per session via _warmedArtists Set.
   Re-rendering a screen no longer re-fires fetches for ids we've
   already seen.

2. Prefetcher only warms artistProvider, not albumProvider. Albums
   carry track lists; pre-warming N albums fans out N parallel
   "fetch tracks" round trips for content the user may never visit.
   Artist rows are single-row lookups — cheap. Album detail loads
   on tap (still fast: server-side perf work makes it ~one round
   trip).

3. Drop alwaysRefresh from albumProvider, artistProvider,
   artistAlbumsProvider, artistTracksProvider. Each was kicking one
   silent background refresh per first cache hit. With the prefetcher
   creating many subscriptions in parallel, that meant every
   prewarmed id triggered an extra fetch even when drift was already
   populated. playlistsListProvider keeps alwaysRefresh — system
   playlists genuinely rotate UUIDs and need the catch-up. Pull-to-
   refresh remains the explicit invalidation path everywhere else.

Removed the warmAlbums calls from the library Albums tab and artist
detail album grid (the storm sources).

Net effect: cold app boot warms ~12-15 artist rows once, period.
Tapping a tile still fetches its detail on demand (one round trip,
fast). User-initiated playback isn't queued behind cache work.
2026-05-11 19:02:16 -04:00

313 lines
12 KiB
Dart

import 'dart:async';
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));
});
final homeProvider = FutureProvider<HomeData>((ref) async {
return (await ref.watch(libraryApiProvider.future)).getHome();
});
/// 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)),
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);
debugPrint('albumProvider($albumId): calling getAlbum');
final fresh = await api
.getAlbum(albumId)
.timeout(const Duration(seconds: 10));
debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift');
// 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());
});
debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit');
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) {
debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch');
if (fetchAttempted) {
// Already tried and got nothing back.
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
continue;
}
fetchAttempted = true;
final online = await isOnline();
debugPrint('albumProvider($albumId): online=$online');
if (!online) {
debugPrint('albumProvider($albumId): offline, yielding empty');
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;
}
debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)');
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) {
debugPrint('albumProvider($albumId): album hit but no tracks; fetching');
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);
}
});