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/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_index.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)); }); /// Drift-first per-item home index. Reads from cached_home_index /// (populated by /api/home/index discovery) and yields a HomeIndex /// the screen consumes to lay out section→tile slots. Each tile is /// rendered by a per-entity tile provider that hydrates itself. /// /// Section keys mirror the server's response shape so the encode / /// decode round-trip is straightforward — the table stores /// (section, position, entityType, entityId), and toResult reassembles /// the parallel ID lists. final homeIndexProvider = StreamProvider((ref) { final db = ref.watch(appDbProvider); final query = (db.select(db.cachedHomeIndex) ..orderBy([ (t) => drift.OrderingTerm.asc(t.section), (t) => drift.OrderingTerm.asc(t.position), ])) .watch(); return cacheFirst( driftStream: query, fetchAndPopulate: () async { final api = await ref.read(libraryApiProvider.future); final fresh = await api.getHomeIndex(); // Full replace in a transaction so the watch() sees exactly one // post-fetch emission with the merged state. Section ordering is // re-asserted on read (orderBy above) so the write order doesn't // matter — letting us batch flat instead of section-by-section. await db.transaction(() async { await db.delete(db.cachedHomeIndex).go(); await db.batch((b) { void rows(String section, String entityType, List ids) { for (var i = 0; i < ids.length; i++) { b.insert( db.cachedHomeIndex, CachedHomeIndexCompanion.insert( section: section, position: i, entityType: entityType, entityId: ids[i], ), ); } } rows('recently_added_albums', 'album', fresh.recentlyAddedAlbums); rows('rediscover_albums', 'album', fresh.rediscoverAlbums); rows('rediscover_artists', 'artist', fresh.rediscoverArtists); rows('most_played_tracks', 'track', fresh.mostPlayedTracks); rows('last_played_artists', 'artist', fresh.lastPlayedArtists); }); }); }, toResult: (rows) { final recentlyAddedAlbums = []; final rediscoverAlbums = []; final rediscoverArtists = []; final mostPlayedTracks = []; final lastPlayedArtists = []; for (final r in rows) { switch (r.section) { case 'recently_added_albums': recentlyAddedAlbums.add(r.entityId); case 'rediscover_albums': rediscoverAlbums.add(r.entityId); case 'rediscover_artists': rediscoverArtists.add(r.entityId); case 'most_played_tracks': mostPlayedTracks.add(r.entityId); case 'last_played_artists': lastPlayedArtists.add(r.entityId); } } return HomeIndex( recentlyAddedAlbums: recentlyAddedAlbums, rediscoverAlbums: rediscoverAlbums, rediscoverArtists: rediscoverArtists, mostPlayedTracks: mostPlayedTracks, lastPlayedArtists: lastPlayedArtists, ); }, isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), // SWR: cached layout shows instantly; fresh /api/home/index lands // in the background and tiles re-resolve as the table mutates. alwaysRefresh: true, tag: 'homeIndex', ); }); /// 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); // LEFT JOIN cached_albums for cover-URL reconstruction (see // CachedArtistAdapter.toRef). First row carries the alphabetically- // first album. final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id))) .join([ drift.leftOuterJoin(db.cachedAlbums, db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)), ]) ..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]); return cacheFirst( driftStream: query.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) { if (rows.isEmpty) return const ArtistRef(id: '', name: ''); final artist = rows.first.readTable(db.cachedArtists); final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums); return artist.toRef(coverAlbumId: firstAlbum?.id ?? ''); }, isOnline: () async => (await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true)), // No alwaysRefresh: artist rows don't change frequently, and the // metadata prefetcher creates many subscriptions in parallel — // each silently re-fetching once would saturate the request // pipeline behind the user's actual playback request. tag: 'artist($id)', ); }); 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) .timeout(const Duration(seconds: 3), onTimeout: () => true)), // SWR: re-fetch the full album list on every visit. Without this, // a previously-incomplete drift cache (e.g. user had only opened // one album by this artist, so cachedAlbums had just that row) // would render forever as a partial list. The prefetcher only // warms artistProvider (single row), so this provider isn't // mass-instantiated and the storm risk that motivated dropping // alwaysRefresh elsewhere doesn't apply here. alwaysRefresh: true, tag: 'artistAlbums($artistId)', ); }); 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) .timeout(const Duration(seconds: 3), onTimeout: () => true)), tag: 'artistTracks($artistId)', ); }); /// 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)), ]); // Once-per-subscription guard so we don't re-fetch in a loop if the // server genuinely returns zero tracks (or if the fetch fails). var fetchAttempted = false; // (revalidated flag removed; see SWR note below the yield.) Future isOnline() async { try { return await ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true); } catch (_) { return true; } } // Cold-cache fetch: pulls the album + its tracks + any unique // artists referenced and writes all three tables in one batch. // Returns true on success (drift watch will re-emit), false on // failure so the caller can yield an empty result. Future fetchAndPopulate() async { try { final api = await ref.read(libraryApiProvider.future); final fresh = await api .getAlbum(albumId) .timeout(const Duration(seconds: 10)); // Collect every artist mentioned by the album + its tracks so // the JOINs that drive both the album header and the track rows // have something to bind to. Without this, drift returns null // for the artist row and artistName surfaces empty — visible // as a missing artist line in the mini player and row list. final artists = {}; if (fresh.album.artistId.isNotEmpty && fresh.album.artistName.isNotEmpty) { artists[fresh.album.artistId] = ArtistRef( id: fresh.album.artistId, name: fresh.album.artistName, ); } for (final t in fresh.tracks) { if (t.artistId.isNotEmpty && t.artistName.isNotEmpty && !artists.containsKey(t.artistId)) { artists[t.artistId] = ArtistRef( id: t.artistId, name: t.artistName, ); } } await db.batch((b) { if (artists.isNotEmpty) { b.insertAllOnConflictUpdate( db.cachedArtists, artists.values.map((a) => a.toDrift()).toList()); } b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]); b.insertAllOnConflictUpdate(db.cachedTracks, fresh.tracks.map((t) => t.toDrift()).toList()); }); return true; } catch (e, st) { debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st'); return false; } } await for (final albumRows in albumQuery.watch()) { // Case 1: no album row at all → cold-fetch. if (albumRows.isEmpty) { if (fetchAttempted) { // Already tried and got nothing back. yield ( album: const AlbumRef(id: '', title: '', artistId: ''), tracks: const [], ); continue; } fetchAttempted = true; if (!await isOnline()) { yield ( album: const AlbumRef(id: '', title: '', artistId: ''), tracks: const [], ); continue; } final ok = await fetchAndPopulate(); if (!ok) { yield ( album: const AlbumRef(id: '', title: '', artistId: ''), tracks: const [], ); } // On success, drift watch re-emits with rows; loop continues. continue; } final albumRow = albumRows.first; final album = albumRow.readTable(db.cachedAlbums).toRef( artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '', ); final trackRows = await tracksQuery.get(); // Case 2: album row exists but no tracks. This happens when an // upstream provider (artistAlbumsProvider, sync, etc.) populated // album rows without their track lists. Trigger the same // fetchAndPopulate so the album becomes complete; drift watch // re-emits and we land in the populated branch on the next pass. if (trackRows.isEmpty && !fetchAttempted) { fetchAttempted = true; if (await isOnline()) { final ok = await fetchAndPopulate(); if (ok) continue; // wait for watch re-emit } // Fall through and yield with the album + empty tracks if the // fetch failed or we're offline. } 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(); // Note: NO SWR here on purpose. Prior code kicked a background // refresh on every first cache hit, which combined with the // metadata prefetcher meant every prewarmed album id triggered // an extra fetch even when drift was already populated. Tracks // and album metadata don't change on the same timescale as // playlists; a stale read is fine until the user invalidates // (pull-to-refresh) or the album is genuinely re-fetched. yield (album: album, tracks: tracks); } });