2299824ad9
System playlists now show in the home row (good!) but tapping them landed on an empty playlist. Same root cause as the earlier album bug: playlistsListProvider wrote the playlist *row* to drift but never the tracks. playlistDetailProvider only triggered cold-fetch when the row was missing, so it yielded with an empty track list. Refactor playlistDetailProvider to mirror albumProvider's approach: - fetchAttempted guard (one-shot per subscription). - Detect "playlist row exists but trackRows empty" and trigger the same fetchAndPopulate the cold-cache path uses. Drift watch re-emits with populated tracks. - 10s timeout on the API fetch + diagnostic prints so any failure surfaces in logs. While here, fix two adjacent issues: - Wipe + re-insert this playlist's track positions on every fetch so server-side deletions actually propagate. Without the wipe, removed tracks would linger in drift forever. - Write the artist + album rows referenced by the fetched tracks into cachedArtists / cachedAlbums (deduped). Without this, the joined artistName/albumTitle columns in the playlist track rows surface empty. Cover art for system playlists is a separate issue — server emits coverUrl but it may be empty for system mixes; PlaylistCard falls back to the queue_music icon. Will tackle in a follow-up.
317 lines
11 KiB
Dart
317 lines
11 KiB
Dart
import 'dart:async';
|
|
|
|
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}');
|
|
await db.batch((b) {
|
|
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 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 = <String, ArtistRefRow>{};
|
|
final albums = <String, AlbumRefRow>{};
|
|
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) {
|
|
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);
|
|
|
|
// SWR: first complete cache hit kicks one background refresh.
|
|
if (!revalidated && trackRows.isNotEmpty) {
|
|
revalidated = true;
|
|
if (await isOnline()) {
|
|
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<SystemPlaylistsStatus>((ref) async {
|
|
final dio = await ref.watch(dioProvider.future);
|
|
return MeApi(dio).systemPlaylistsStatus();
|
|
});
|