diff --git a/flutter_client/lib/library/library_providers.dart b/flutter_client/lib/library/library_providers.dart index f212d8d7..25fbec9b 100644 --- a/flutter_client/lib/library/library_providers.dart +++ b/flutter_client/lib/library/library_providers.dart @@ -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 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 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 = {}; + 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 = {}; - 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 [], + 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 [], + ); + 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 [], ); + continue; } + final ok = await fetchAndPopulate(); + if (!ok) { + yield ( + album: const AlbumRef(id: '', title: '', artistId: ''), + tracks: const [], + ); + } + // 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);