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((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((ref) async { return LibraryApi(await ref.watch(dioProvider.future)); }); final homeProvider = FutureProvider((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((ref, id) { final db = ref.watch(appDbProvider); return cacheFirst( 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, 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>( 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, 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>( 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 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 [], ); } } else { yield ( album: const AlbumRef(id: '', title: '', artistId: ''), tracks: const [], ); } 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); } });