fix(flutter): refetch album when row exists but tracks don't

artistAlbumsProvider populates cachedAlbums (album rows only) without
ever fetching their tracks. When the user taps from the artist grid
into an album, albumProvider sees a drift hit on the album row, never
triggers cold-cache, and yields with an empty track list — visible as
the album header rendering above an empty body.

Refactor albumProvider's cold-cache logic into a helper closure that
either branch (no album row OR album row + no tracks) can call. Add a
fetchAttempted guard so a server response that legitimately returns
zero tracks doesn't loop forever.

Decision flow:
- albumRows empty: cold-fetch (existing path), yield empty on
  failure/offline
- albumRows hit but trackRows empty AND not yet attempted: same
  cold-fetch — drift watch re-emits with the populated tracks
- albumRows hit, trackRows hit (or attempted already): yield as-is
This commit is contained in:
2026-05-11 08:52:45 -04:00
parent 983a9d92be
commit 727a0760da
+105 -62
View File
@@ -157,77 +157,103 @@ final albumProvider = StreamProvider.family<
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
]);
await for (final albumRows in albumQuery.watch()) {
if (albumRows.isEmpty) {
debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch');
// Cold cache fallback
bool online;
try {
online = await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
} catch (e) {
debugPrint('albumProvider($albumId): connectivity check threw $e — assuming online');
online = true;
// 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;
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,
);
}
debugPrint('albumProvider($albumId): online=$online');
if (online) {
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
// the 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');
// watch() re-emits with the populated rows; loop continues.
} catch (e, st) {
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
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,
);
}
} else {
}
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)');
@@ -238,6 +264,23 @@ final albumProvider = StreamProvider.family<
);
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);