diff --git a/flutter_client/lib/playlists/playlists_provider.dart b/flutter_client/lib/playlists/playlists_provider.dart index 6507fd87..c0164b5b 100644 --- a/flutter_client/lib/playlists/playlists_provider.dart +++ b/flutter_client/lib/playlists/playlists_provider.dart @@ -135,47 +135,134 @@ final playlistDetailProvider = } } - Future fetchAndPopulate() async { - final api = await ref.read(playlistsApiProvider.future); - final fresh = await api.get(id); - await db.batch((b) { - b.insert(db.cachedPlaylists, fresh.playlist.toDrift(), - mode: drift.InsertMode.insertOrReplace); - for (var i = 0; i < fresh.tracks.length; i++) { - final t = fresh.tracks[i]; - if (t.trackId == null) continue; - b.insert( - db.cachedPlaylistTracks, - CachedPlaylistTracksCompanion.insert( - playlistId: id, - trackId: t.trackId!, - position: drift.Value(i), - ), - mode: drift.InsertMode.insertOrReplace, - ); + Future fetchAndPopulate() async { + try { + final api = await ref.read(playlistsApiProvider.future); + debugPrint('playlistDetailProvider($id): calling get'); + final fresh = await api.get(id).timeout(const Duration(seconds: 10)); + debugPrint( + 'playlistDetailProvider($id): got ${fresh.tracks.length} tracks, writing'); + + // Collect the artist + album rows referenced by these tracks so + // the JOINs that build artistName/albumTitle in the row view + // have something to bind to. Without this, system-playlist tracks + // surface with empty artist/album columns. + final artists = {}; + final albums = {}; + for (final t in fresh.tracks) { + final aId = t.artistId; + final aName = t.artistName; + if (aId != null && aId.isNotEmpty && aName.isNotEmpty) { + artists.putIfAbsent(aId, () => ArtistRefRow(aId, aName)); + } + final lbId = t.albumId; + final lbTitle = t.albumTitle; + if (lbId != null && lbId.isNotEmpty && lbTitle.isNotEmpty) { + albums.putIfAbsent(lbId, () => AlbumRefRow(lbId, lbTitle, aId ?? '')); + } } - }); + + await db.batch((b) { + b.insert(db.cachedPlaylists, fresh.playlist.toDrift(), + mode: drift.InsertMode.insertOrReplace); + + // Wipe + re-insert this playlist's track positions so deletions + // propagate. Without the wipe, removed tracks would linger in + // drift after the playlist mutated server-side. + b.deleteWhere( + db.cachedPlaylistTracks, (t) => t.playlistId.equals(id)); + + for (var i = 0; i < fresh.tracks.length; i++) { + final t = fresh.tracks[i]; + if (t.trackId == null) continue; + b.insert( + db.cachedPlaylistTracks, + CachedPlaylistTracksCompanion.insert( + playlistId: id, + trackId: t.trackId!, + position: drift.Value(i), + ), + mode: drift.InsertMode.insertOrReplace, + ); + } + + if (artists.isNotEmpty) { + b.insertAllOnConflictUpdate( + db.cachedArtists, + artists.values + .map((a) => CachedArtistsCompanion.insert( + id: a.id, + name: a.name, + sortName: a.name, + )) + .toList(), + ); + } + if (albums.isNotEmpty) { + b.insertAllOnConflictUpdate( + db.cachedAlbums, + albums.values + .map((a) => CachedAlbumsCompanion.insert( + id: a.id, + artistId: a.artistId, + title: a.title, + sortTitle: a.title, + )) + .toList(), + ); + } + }); + debugPrint( + 'playlistDetailProvider($id): drift write done; awaiting watch re-emit'); + return true; + } catch (e, st) { + debugPrint('playlistDetailProvider($id): fetch failed: $e\n$st'); + return false; + } } + // Once-per-subscription guard so an empty server response doesn't + // cause repeated re-fetches. + var fetchAttempted = false; var revalidated = false; await for (final playlistRows in playlistQuery.watch()) { if (playlistRows.isEmpty) { - if (await isOnline()) { - try { - await fetchAndPopulate(); - // watch() re-emits next iteration with populated row. - } catch (_) { - yield emptyDetail(); - } - } else { + debugPrint('playlistDetailProvider($id): drift miss, cold-fetching'); + if (fetchAttempted) { yield emptyDetail(); + continue; } + fetchAttempted = true; + if (!await isOnline()) { + yield emptyDetail(); + continue; + } + final ok = await fetchAndPopulate(); + if (!ok) yield emptyDetail(); continue; } + debugPrint( + 'playlistDetailProvider($id): drift hit (${playlistRows.length} rows)'); + final playlist = playlistRows.first.toRef(); final trackRows = await tracksQuery.get(); + + // Same pattern as albumProvider: playlist row exists but no tracks + // (e.g., playlistsListProvider wrote the row, sync hasn't carried + // tracks for system playlists). Trigger the same fetch, drift + // watch re-emits with populated tracks. + if (trackRows.isEmpty && !fetchAttempted) { + debugPrint( + 'playlistDetailProvider($id): playlist hit but no tracks; fetching'); + fetchAttempted = true; + if (await isOnline()) { + final ok = await fetchAndPopulate(); + if (ok) continue; // wait for watch re-emit + } + } + final tracks = trackRows.asMap().entries.map((e) { final r = e.value; final track = r.readTableOrNull(db.cachedTracks); @@ -196,18 +283,32 @@ final playlistDetailProvider = yield PlaylistDetail(playlist: playlist, tracks: tracks); - // SWR: first complete cache hit kicks one background refresh so - // the playlist catches up to server state (new tracks, renames, - // etc.) without making the user wait. - if (!revalidated) { + // SWR: first complete cache hit kicks one background refresh. + if (!revalidated && trackRows.isNotEmpty) { revalidated = true; if (await isOnline()) { - unawaited(fetchAndPopulate().catchError((_) {})); + unawaited(fetchAndPopulate()); } } } }); +/// Lightweight tuples used inside fetchAndPopulate to dedupe artist +/// and album rows referenced by playlist tracks before we batch-write +/// them to drift. +class ArtistRefRow { + ArtistRefRow(this.id, this.name); + final String id; + final String name; +} + +class AlbumRefRow { + AlbumRefRow(this.id, this.title, this.artistId); + final String id; + final String title; + final String artistId; +} + final systemPlaylistsStatusProvider = FutureProvider((ref) async { final dio = await ref.watch(dioProvider.future);