94727f40bb
- cache_first.dart: backtick-fence List<T> doc comment to dodge unintended_html_in_doc_comment. - library_providers.dart:163: switch single-row insert to insertAllOnConflictUpdate; Batch only exposes the *AllOnConflict* variant. - 5 test files: StreamProvider.overrideWith takes Create<Stream<T>>, not Create<Future<T>>. Wrap data emissions in Stream.value(...). - artist_detail_screen_test: add models/album.dart import for AlbumRef type annotation. - track_actions_sheet_test: drop _StubLiked extends LikedIdsController (notifier no longer exists post-migration); override with Stream.value(LikedIds(...)) instead. - like_button_test: rollback assertion now requires drift writes via LikesController. Skip under _skipDrift until libsqlite3-dev lands on the runner image (Fable #399). Replace stale .notifier reference with likesControllerProvider for completeness. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
7.2 KiB
Dart
201 lines
7.2 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../api/client.dart';
|
|
import '../api/endpoints/library.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 '../models/album.dart';
|
|
import '../models/artist.dart';
|
|
import '../models/home_data.dart';
|
|
import '../models/track.dart';
|
|
|
|
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
|
|
/// to the secure-storage `session_token` read — every other endpoint
|
|
/// awaits `dioProvider.future` rather than constructing its own dio.
|
|
///
|
|
/// Riverpod will invalidate any FutureProvider that watches this when
|
|
/// the server URL changes; auth state changes don't need to invalidate
|
|
/// the provider because the resolver re-reads the token on every request.
|
|
final dioProvider = FutureProvider<Dio>((ref) async {
|
|
final url = await ref.watch(serverUrlProvider.future);
|
|
if (url == null || url.isEmpty) {
|
|
throw StateError('no server URL set');
|
|
}
|
|
final storage = ref.watch(secureStorageProvider);
|
|
return ApiClient.buildDio(
|
|
baseUrl: url,
|
|
tokenResolver: () async => storage.read(key: 'session_token'),
|
|
on401: () async => ref.read(authControllerProvider.notifier).clearSession(),
|
|
);
|
|
});
|
|
|
|
final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
|
|
return LibraryApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
final homeProvider = FutureProvider<HomeData>((ref) async {
|
|
return (await ref.watch(libraryApiProvider.future)).getHome();
|
|
});
|
|
|
|
/// Drift-first per #357 plan C. Watches cached_artists for the row;
|
|
/// when empty + online, fetches via REST + populates drift, which
|
|
/// re-emits via watch().
|
|
final artistProvider =
|
|
StreamProvider.family<ArtistRef, String>((ref, id) {
|
|
final db = ref.watch(appDbProvider);
|
|
return cacheFirst<CachedArtist, ArtistRef>(
|
|
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
|
.watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getArtist(id);
|
|
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
|
},
|
|
toResult: (rows) => rows.isEmpty
|
|
? const ArtistRef(id: '', name: '')
|
|
: rows.first.toRef(),
|
|
isOnline: () async =>
|
|
(await ref.read(connectivityProvider.future)),
|
|
);
|
|
});
|
|
|
|
final artistAlbumsProvider =
|
|
StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
|
|
final db = ref.watch(appDbProvider);
|
|
// Join cached_albums + cached_artists to fill artist_name on each row.
|
|
final query = db.select(db.cachedAlbums).join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
|
])..where(db.cachedAlbums.artistId.equals(artistId));
|
|
|
|
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
|
|
driftStream: query.watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getArtistAlbums(artistId);
|
|
await db.batch((b) {
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedAlbums, fresh.map((a) => a.toDrift()).toList());
|
|
});
|
|
},
|
|
toResult: (rows) => rows.map((r) {
|
|
final album = r.readTable(db.cachedAlbums);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
return album.toRef(artistName: artist?.name ?? '');
|
|
}).toList(),
|
|
isOnline: () async =>
|
|
(await ref.read(connectivityProvider.future)),
|
|
);
|
|
});
|
|
|
|
final artistTracksProvider =
|
|
StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
|
|
final db = ref.watch(appDbProvider);
|
|
final query = db.select(db.cachedTracks).join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
|
drift.leftOuterJoin(db.cachedAlbums,
|
|
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
|
|
])..where(db.cachedTracks.artistId.equals(artistId));
|
|
|
|
return cacheFirst<drift.TypedResult, List<TrackRef>>(
|
|
driftStream: query.watch(),
|
|
fetchAndPopulate: () async {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getArtistTracks(artistId);
|
|
await db.batch((b) {
|
|
b.insertAllOnConflictUpdate(
|
|
db.cachedTracks, fresh.map((t) => t.toDrift()).toList());
|
|
});
|
|
},
|
|
toResult: (rows) => rows.map((r) {
|
|
final track = r.readTable(db.cachedTracks);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
final album = r.readTableOrNull(db.cachedAlbums);
|
|
return track.toRef(
|
|
artistName: artist?.name ?? '',
|
|
albumTitle: album?.title ?? '',
|
|
);
|
|
}).toList(),
|
|
isOnline: () async =>
|
|
(await ref.read(connectivityProvider.future)),
|
|
);
|
|
});
|
|
|
|
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
|
|
/// for reactive updates over both rows.
|
|
final albumProvider = StreamProvider.family<
|
|
({AlbumRef album, List<TrackRef> tracks}), String>((ref, albumId) async* {
|
|
final db = ref.watch(appDbProvider);
|
|
|
|
final albumQuery = (db.select(db.cachedAlbums)
|
|
..where((t) => t.id.equals(albumId)))
|
|
.join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
|
|
]);
|
|
|
|
final tracksQuery = (db.select(db.cachedTracks)
|
|
..where((t) => t.albumId.equals(albumId))
|
|
..orderBy([
|
|
(t) => drift.OrderingTerm.asc(t.discNumber),
|
|
(t) => drift.OrderingTerm.asc(t.trackNumber),
|
|
]))
|
|
.join([
|
|
drift.leftOuterJoin(db.cachedArtists,
|
|
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
|
|
]);
|
|
|
|
await for (final albumRows in albumQuery.watch()) {
|
|
if (albumRows.isEmpty) {
|
|
// Cold cache fallback
|
|
if (await ref.read(connectivityProvider.future)) {
|
|
try {
|
|
final api = await ref.read(libraryApiProvider.future);
|
|
final fresh = await api.getAlbum(albumId);
|
|
await db.batch((b) {
|
|
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
|
|
b.insertAllOnConflictUpdate(db.cachedTracks,
|
|
fresh.tracks.map((t) => t.toDrift()).toList());
|
|
});
|
|
// watch() re-emits with the populated rows; loop continues.
|
|
} catch (_) {
|
|
yield (
|
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
|
tracks: const <TrackRef>[],
|
|
);
|
|
}
|
|
} else {
|
|
yield (
|
|
album: const AlbumRef(id: '', title: '', artistId: ''),
|
|
tracks: const <TrackRef>[],
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
final albumRow = albumRows.first;
|
|
final album = albumRow.readTable(db.cachedAlbums).toRef(
|
|
artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '',
|
|
);
|
|
|
|
final trackRows = await tracksQuery.get();
|
|
final tracks = trackRows.map((r) {
|
|
final track = r.readTable(db.cachedTracks);
|
|
final artist = r.readTableOrNull(db.cachedArtists);
|
|
return track.toRef(
|
|
artistName: artist?.name ?? '',
|
|
albumTitle: album.title,
|
|
);
|
|
}).toList();
|
|
|
|
yield (album: album, tracks: tracks);
|
|
}
|
|
});
|