d27dd69bfc
Smoke test failed: "A Timer is still pending even after the widget tree was disposed." Both workers fired their initial-delay Timer via `Timer(duration, _sweep)` and stored only the periodic ticker in the cancellable field — the one-shot Timer leaked past dispose and tripped the test framework's invariant check. Track both as _initialTimer + _intervalTimer; cancel both in dispose(). Behavior is unchanged in production (ref.onDispose only fires on process death normally); this is purely a test-harness fix.
236 lines
8.3 KiB
Dart
236 lines
8.3 KiB
Dart
// 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<void> _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<void> _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<String>('id')).toList();
|
|
if (ids.isEmpty) return;
|
|
|
|
final api = await _ref.read(libraryApiProvider.future);
|
|
final newAlbumIds = <String>[];
|
|
|
|
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<void> _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<String>('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<void> _prewarmCovers(List<String> 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'}
|
|
: <String, String>{};
|
|
final base = baseUrl.endsWith('/')
|
|
? baseUrl.substring(0, baseUrl.length - 1)
|
|
: baseUrl;
|
|
final mgr = DefaultCacheManager();
|
|
|
|
var idx = 0;
|
|
Future<void> 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<CacheFiller>((ref) {
|
|
final filler = CacheFiller(ref);
|
|
ref.onDispose(filler.dispose);
|
|
filler.start();
|
|
return filler;
|
|
});
|