// Background metadata sweeper that walks the drift cache for missing // relations and fills them via /api/artists/:id + /api/albums/:id. // Cover bytes for newly-filled albums are pre-warmed too so the // per-tile display path doesn't have to wait on network. // // Why this layer on top of SyncController and HydrationQueue: // - SyncController.sync only ingests entities the server emitted // into the library_changes delta for this user. For a fresh // install, that's everything; for an existing user, only recent // changes. The per-artist album list and per-album track list // are NOT in those deltas — they're derived at query time. // - HydrationQueue fills these lazily on tile render. CacheFiller // fills them proactively on a slow schedule so tapping an artist // surfaces albums immediately instead of triggering a fresh // /api/artists/:id at tap time. // // Pacing is conservative — 200ms between requests, max 200 entities // per sweep, 5-minute interval. Designed to never compete with user // activity. Wall time on a 1000-artist library: ~3-4 minutes spread // over multiple sweeps. import 'dart:async'; import 'package:drift/drift.dart' show Variable; import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_provider.dart' show serverUrlProvider, sessionTokenProvider; import '../cache/adapters.dart'; import '../cache/audio_cache_manager.dart' show appDbProvider; import '../cache/connectivity_provider.dart'; import '../library/library_providers.dart' show libraryApiProvider; class CacheFiller { CacheFiller(this._ref); final Ref _ref; Timer? _initialTimer; Timer? _intervalTimer; bool _running = false; bool _disposed = false; /// Delay between launch and the first sweep. Long enough for /// SyncController to land its initial /api/library/sync so the /// CacheFiller's "unfilled relations" query has meaningful work. static const _initialDelay = Duration(seconds: 10); /// Cadence between sweeps. Once a sweep finds nothing to do (steady /// state) the interval becomes a cheap drift query + early exit. static const _interval = Duration(minutes: 5); /// Throttle between per-entity REST requests so the filler doesn't /// saturate the server or compete with user-initiated playback. static const _requestThrottle = Duration(milliseconds: 200); /// Per-sweep cap. Without this, a fresh install with thousands of /// artists would tie up the network for many minutes in one go. /// The next sweep continues where this one left off (the WHERE /// NOT EXISTS query naturally skips already-filled rows). static const _maxIdsPerSweep = 200; void start() { _initialTimer = Timer(_initialDelay, _sweep); _intervalTimer = Timer.periodic(_interval, (_) => _sweep()); } Future _sweep() async { if (_disposed || _running) return; final online = await _ref .read(connectivityProvider.future) .timeout(const Duration(seconds: 3), onTimeout: () => true); if (!online) return; _running = true; try { await _fillArtists(); if (_disposed) return; await _fillAlbums(); } catch (e, st) { debugPrint('cache_filler: sweep failed: $e\n$st'); } finally { _running = false; } } /// Find artists with no albums in cache and fetch /api/artists/:id /// for each (single round-trip yields artist + albums via the new /// getArtistDetail API method). Newly-discovered album covers /// land in flutter_cache_manager's disk cache too so the next /// visit to the artist's albums grid paints from disk. Future _fillArtists() async { final db = _ref.read(appDbProvider); final rows = await db.customSelect( ''' SELECT a.id FROM cached_artists a WHERE NOT EXISTS ( SELECT 1 FROM cached_albums b WHERE b.artist_id = a.id ) LIMIT ? ''', variables: [Variable.withInt(_maxIdsPerSweep)], ).get(); final ids = rows.map((r) => r.read('id')).toList(); if (ids.isEmpty) return; final api = await _ref.read(libraryApiProvider.future); final newAlbumIds = []; for (final id in ids) { if (_disposed) return; try { final detail = await api.getArtistDetail(id); await db.transaction(() async { await db .into(db.cachedArtists) .insertOnConflictUpdate(detail.artist.toDrift()); if (detail.albums.isNotEmpty) { await db.batch((b) { b.insertAllOnConflictUpdate( db.cachedAlbums, detail.albums.map((a) => a.toDrift()).toList(), ); }); newAlbumIds.addAll(detail.albums.map((a) => a.id)); } }); } catch (e) { debugPrint('cache_filler: fillArtist($id) failed: $e'); } await Future.delayed(_requestThrottle); } if (newAlbumIds.isNotEmpty) { await _prewarmCovers(newAlbumIds); } } /// Find albums with no tracks in cache and fetch /api/albums/:id /// for each. The bulk response carries the album's track list which /// we persist into cached_tracks for instant album detail render /// on the next visit. Future _fillAlbums() async { final db = _ref.read(appDbProvider); final rows = await db.customSelect( ''' SELECT a.id FROM cached_albums a WHERE NOT EXISTS ( SELECT 1 FROM cached_tracks t WHERE t.album_id = a.id ) LIMIT ? ''', variables: [Variable.withInt(_maxIdsPerSweep)], ).get(); final ids = rows.map((r) => r.read('id')).toList(); if (ids.isEmpty) return; final api = await _ref.read(libraryApiProvider.future); for (final id in ids) { if (_disposed) return; try { final result = await api.getAlbum(id); if (result.tracks.isNotEmpty) { await db.batch((b) { b.insertAllOnConflictUpdate( db.cachedTracks, result.tracks.map((t) => t.toDrift()).toList(), ); }); } } catch (e) { debugPrint('cache_filler: fillAlbum($id) failed: $e'); } await Future.delayed(_requestThrottle); } } /// Pre-warm album cover bytes via flutter_cache_manager so the /// per-tile ServerImage / CachedNetworkImage paints from disk on /// the user's first visit. Mirrors SyncController's prewarm /// helper — duplicated here intentionally to keep this file self- /// contained; a shared helper can come out of #357 follow-up if /// the duplication grows. Future _prewarmCovers(List albumIds) async { final baseUrl = await _ref.read(serverUrlProvider.future); if (baseUrl == null || baseUrl.isEmpty) return; final token = await _ref.read(sessionTokenProvider.future); final headers = (token != null && token.isNotEmpty) ? {'Authorization': 'Bearer $token'} : {}; final base = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; final mgr = DefaultCacheManager(); var idx = 0; Future worker() async { while (idx < albumIds.length) { if (_disposed) return; final i = idx++; try { await mgr.downloadFile( '$base/api/albums/${albumIds[i]}/cover', authHeaders: headers, ); } catch (_) { // Best-effort prewarm — 404 (missing collage) and 401 // (token refresh races) shouldn't abort the rest. } } } // Concurrency 3 — same as SyncController's prewarm. Covers are // small enough that more parallelism doesn't help much and // burns radio. await Future.wait(List.generate(3, (_) => worker())); } void dispose() { _disposed = true; _initialTimer?.cancel(); _intervalTimer?.cancel(); } } /// Read once at app start (from app.dart's postFrameCallback) to /// activate the filler. Disposed via ref.onDispose when the provider /// scope tears down; in practice the scope lives for the app's /// lifetime so dispose only fires on uninstall / process death. final cacheFillerProvider = Provider((ref) { final filler = CacheFiller(ref); ref.onDispose(filler.dispose); filler.start(); return filler; });