Files
minstrel/flutter_client/lib/playlists/playlists_provider.dart
T
bvandeusen e856172d60 chore(flutter): drop success-path diagnostic prints
Cleanup pass on the noisy debugPrints we added during the recent
debugging sessions. Remaining prints are error-path only:
- AlbumCoverCache fetch failed
- album/playlist cold-cache fetch failed
- audio_handler playbackEventStream error
- audio_handler queue supersession (rare race)
- audio_handler forward/backward fill failed
- artist_detail play failed
- cacheFirst fetchAndPopulate failed (only when tag is set)

Removed per-event chatter:
- audio_handler player-state and processingState subscribers
- audio_handler timing measurements (built initial / setAudioSources
  / forward fill / backward fill)
- audio_handler cache hit/miss per-track (fired N times per play)
- audio_handler register stream cache (verifiable via Settings)
- audio_handler.configure log
- albumProvider step-by-step lines (drift miss / online / drift hit /
  album hit but no tracks)
- playlistDetailProvider step-by-step lines (calling get / drift
  write done / 404 evicted / drift hit / playlist hit but no tracks)
- playlistsListProvider wire returned + drift rows lines
- playTracks stage timings (serverUrl / token / configure / setQueue
  / play returned)
- metadataPrefetcher warming N artists
- artist_detail play tapped — N tracks success log
- cacheFirst step-by-step (drift hit / drift miss / online / done)

`tag` parameter on cacheFirst preserved for ad-hoc instrumentation.
2026-05-11 23:16:42 -04:00

370 lines
13 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);
// 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();
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);
final fresh = await api.get(id).timeout(const Duration(seconds: 10));
// 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(),
);
}
});
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) {
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) {
if (fetchAttempted) {
yield emptyDetail();
continue;
}
fetchAttempted = true;
if (!await isOnline()) {
yield emptyDetail();
continue;
}
final ok = await fetchAndPopulate();
if (!ok) yield emptyDetail();
continue;
}
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) {
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();
});