4ede37d9ad
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.
395 lines
14 KiB
Dart
395 lines
14 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/endpoints/me.dart';
|
|
import '../api/endpoints/playlists.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 '../library/library_providers.dart' show dioProvider;
|
|
import '../models/playlist.dart';
|
|
import '../models/system_playlists_status.dart';
|
|
|
|
final playlistsApiProvider = FutureProvider<PlaylistsApi>((ref) async {
|
|
return PlaylistsApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
/// Drift-first per #357 plan C. Returns cached playlists filtered to
|
|
/// match the `kind` family arg:
|
|
/// - 'user' → only user-created (systemVariant null), owned by current user
|
|
/// - 'system' → only system-generated (systemVariant non-null), owned
|
|
/// - 'all' → everything: own user playlists + own system + others' public
|
|
///
|
|
/// systemVariant column added in drift schema v2 (#357 follow-up); previous
|
|
/// behavior leaked system playlists into add-to-playlist sheet because we
|
|
/// couldn't filter locally.
|
|
final playlistsListProvider =
|
|
StreamProvider.family<PlaylistsList, String>((ref, kind) {
|
|
final db = ref.watch(appDbProvider);
|
|
final user = ref.watch(authControllerProvider).value;
|
|
|
|
return cacheFirst<CachedPlaylist, PlaylistsList>(
|
|
driftStream: db.select(db.cachedPlaylists).watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(playlistsApiProvider.future);
|
|
final fresh = await api.list(kind: kind);
|
|
final ownedSysCount =
|
|
fresh.owned.where((p) => p.systemVariant != null).length;
|
|
debugPrint(
|
|
'playlistsListProvider($kind): wire returned owned=${fresh.owned.length} '
|
|
'(system=$ownedSysCount) public=${fresh.public.length}');
|
|
|
|
// Reconcile: BuildSystemPlaylists rotates system-playlist UUIDs
|
|
// every rebuild, so insertOrReplace alone leaves stale rows in
|
|
// drift. Tapping one of those stale tiles 404s. Delete every
|
|
// owned drift row whose id isn't in the fresh response, then
|
|
// upsert the fresh set.
|
|
final freshOwnedIds =
|
|
fresh.owned.map((p) => p.id).toSet();
|
|
await db.batch((b) {
|
|
if (user != null) {
|
|
b.deleteWhere(db.cachedPlaylists, (t) {
|
|
return t.userId.equals(user.id) &
|
|
t.id.isNotIn(freshOwnedIds);
|
|
});
|
|
}
|
|
for (final p in fresh.all) {
|
|
b.insert(db.cachedPlaylists, p.toDrift(),
|
|
mode: drift.InsertMode.insertOrReplace);
|
|
}
|
|
});
|
|
},
|
|
toResult: (rows) {
|
|
if (user == null) return PlaylistsList.empty();
|
|
final filtered = rows.where((r) {
|
|
if (kind == 'user') return r.systemVariant == null;
|
|
if (kind == 'system') return r.systemVariant != null;
|
|
return true; // 'all'
|
|
}).toList();
|
|
final owned = filtered
|
|
.where((r) => r.userId == user.id)
|
|
.map((r) => r.toRef())
|
|
.toList();
|
|
final pub = filtered
|
|
.where((r) => r.userId != user.id && r.isPublic)
|
|
.map((r) => r.toRef())
|
|
.toList();
|
|
final ownedSys = owned.where((p) => p.isSystem).length;
|
|
final driftSys = filtered.where((r) => r.systemVariant != null).length;
|
|
debugPrint(
|
|
'playlistsListProvider($kind): drift rows=${rows.length} '
|
|
'filtered=${filtered.length} (system=$driftSys) '
|
|
'owned=${owned.length} (system=$ownedSys) pub=${pub.length} '
|
|
'userId=${user.id}');
|
|
return PlaylistsList(owned: owned, public: pub);
|
|
},
|
|
isOnline: () async => (await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
|
// Aggregate list — server may add system playlists (For-You /
|
|
// Discover) that the per-user delta sync doesn't always emit.
|
|
// Stale-while-revalidate ensures the home tile row reflects the
|
|
// current server state on every screen visit.
|
|
alwaysRefresh: true,
|
|
);
|
|
});
|
|
|
|
/// Composite shape (playlist + tracks). async* over the playlist watch
|
|
/// stream + a one-shot tracks fetch per emission.
|
|
final playlistDetailProvider =
|
|
StreamProvider.family<PlaylistDetail, String>((ref, id) async* {
|
|
final db = ref.watch(appDbProvider);
|
|
final user = ref.watch(authControllerProvider).value;
|
|
|
|
final playlistQuery = db.select(db.cachedPlaylists)
|
|
..where((t) => t.id.equals(id));
|
|
|
|
final tracksQuery = (db.select(db.cachedPlaylistTracks)
|
|
..where((t) => t.playlistId.equals(id))
|
|
..orderBy([(t) => drift.OrderingTerm.asc(t.position)]))
|
|
.join([
|
|
drift.leftOuterJoin(db.cachedTracks,
|
|
db.cachedTracks.id.equalsExp(db.cachedPlaylistTracks.trackId)),
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
|
drift.leftOuterJoin(db.cachedAlbums,
|
|
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
|
]);
|
|
|
|
PlaylistDetail emptyDetail() => PlaylistDetail(
|
|
playlist: Playlist(
|
|
id: id,
|
|
userId: user?.id ?? '',
|
|
name: '',
|
|
description: '',
|
|
isPublic: false,
|
|
systemVariant: null,
|
|
trackCount: 0,
|
|
coverUrl: '',
|
|
ownerUsername: '',
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
),
|
|
tracks: const [],
|
|
);
|
|
|
|
Future<bool> isOnline() async {
|
|
try {
|
|
return await ref
|
|
.read(connectivityProvider.future)
|
|
.timeout(const Duration(seconds: 3), onTimeout: () => true);
|
|
} catch (_) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
Future<bool> 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 track + artist + album rows referenced by these
|
|
// playlist entries. Without writing the cachedTracks rows
|
|
// themselves, the detail-screen JOIN against cachedTracks
|
|
// returns null on every row (only the playlist_tracks join
|
|
// succeeds), so the UI shows a list with empty titles and
|
|
// unplayable tracks.
|
|
final tracks = <String, _TrackRefRow>{};
|
|
final artists = <String, ArtistRefRow>{};
|
|
final albums = <String, AlbumRefRow>{};
|
|
for (final t in fresh.tracks) {
|
|
final tId = t.trackId;
|
|
if (tId != null && tId.isNotEmpty) {
|
|
tracks.putIfAbsent(
|
|
tId,
|
|
() => _TrackRefRow(
|
|
id: tId,
|
|
title: t.title,
|
|
albumId: t.albumId ?? '',
|
|
artistId: t.artistId ?? '',
|
|
durationSec: t.durationSec,
|
|
),
|
|
);
|
|
}
|
|
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 (tracks.isNotEmpty) {
|
|
// Insert/update cachedTracks so the detail screen's JOIN
|
|
// produces real titles + durations. We don't have
|
|
// track_number / disc_number on the wire (PlaylistTrack
|
|
// omits them), so they default to 0 — UI doesn't surface
|
|
// them on this screen so it's fine.
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedTracks,
|
|
tracks.values
|
|
.map((t) => CachedTracksCompanion.insert(
|
|
id: t.id,
|
|
albumId: t.albumId,
|
|
artistId: t.artistId,
|
|
title: t.title,
|
|
durationMs: drift.Value(t.durationSec * 1000),
|
|
))
|
|
.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) {
|
|
// 404 = the playlist row in drift is stale (BuildSystemPlaylists
|
|
// rotates UUIDs on each rebuild, so old For-You / Songs-Like
|
|
// tiles can outlive the actual server-side playlist). Wipe the
|
|
// stale row + its track positions so the home tile disappears
|
|
// on next render and we don't keep trying.
|
|
if (e is DioException && e.response?.statusCode == 404) {
|
|
debugPrint(
|
|
'playlistDetailProvider($id): server says 404, evicting stale drift rows');
|
|
await db.batch((b) {
|
|
b.deleteWhere(
|
|
db.cachedPlaylistTracks, (t) => t.playlistId.equals(id));
|
|
b.deleteWhere(db.cachedPlaylists, (t) => t.id.equals(id));
|
|
});
|
|
return false;
|
|
}
|
|
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;
|
|
|
|
await for (final playlistRows in playlistQuery.watch()) {
|
|
if (playlistRows.isEmpty) {
|
|
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);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
final album = r.readTableOrNull(db.cachedAlbums);
|
|
return PlaylistTrack(
|
|
position: e.key,
|
|
trackId: track?.id,
|
|
title: track?.title ?? '',
|
|
albumId: album?.id,
|
|
albumTitle: album?.title ?? '',
|
|
artistId: artist?.id,
|
|
artistName: artist?.name ?? '',
|
|
durationSec: track == null ? 0 : track.durationMs ~/ 1000,
|
|
streamUrl: null,
|
|
);
|
|
}).toList();
|
|
|
|
yield PlaylistDetail(playlist: playlist, tracks: tracks);
|
|
|
|
// No SWR refresh here. The aggregate playlistsListProvider does
|
|
// alwaysRefresh (system playlists rotate UUIDs), but per-detail
|
|
// refresh on every visit was multiplying with the prefetcher's
|
|
// parallel fetches and starving user-initiated playback. Pull-
|
|
// to-refresh on the detail page invalidates the provider, which
|
|
// is the right path for an explicit refresh.
|
|
}
|
|
});
|
|
|
|
/// 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;
|
|
}
|
|
|
|
class _TrackRefRow {
|
|
_TrackRefRow({
|
|
required this.id,
|
|
required this.title,
|
|
required this.albumId,
|
|
required this.artistId,
|
|
required this.durationSec,
|
|
});
|
|
final String id;
|
|
final String title;
|
|
final String albumId;
|
|
final String artistId;
|
|
final int durationSec;
|
|
}
|
|
|
|
final systemPlaylistsStatusProvider =
|
|
FutureProvider<SystemPlaylistsStatus>((ref) async {
|
|
final dio = await ref.watch(dioProvider.future);
|
|
return MeApi(dio).systemPlaylistsStatus();
|
|
});
|