diff --git a/flutter_client/lib/cache/audio_cache_manager.dart b/flutter_client/lib/cache/audio_cache_manager.dart new file mode 100644 index 00000000..9733eba9 --- /dev/null +++ b/flutter_client/lib/cache/audio_cache_manager.dart @@ -0,0 +1,166 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../library/library_providers.dart' show dioProvider; +import 'db.dart'; + +/// Owns the audio cache directory + drift index. +/// API: +/// - isCached(trackId) +/// - pathFor(trackId) +/// - pin(trackId, source) — downloads + indexes +/// - unpin(trackId) +/// - evict(targetBytes) — tiered LRU +/// - usageBytes() +/// - clearAll() +class AudioCacheManager { + AudioCacheManager({ + required AppDb db, + required Future Function() dioFactory, + Future Function()? cacheDirFactory, + }) : _db = db, + _dioFactory = dioFactory, + _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory; + + final AppDb _db; + final Future Function() _dioFactory; + final Future Function() _cacheDirFactory; + + Future _tracksDir() async { + final base = await _cacheDirFactory(); + final d = Directory('${base.path}/audio_cache'); + if (!await d.exists()) await d.create(recursive: true); + return d.path; + } + + /// True if the trackId has a complete file on disk. + Future isCached(String trackId) async { + final row = await (_db.select(_db.audioCacheIndex) + ..where((t) => t.trackId.equals(trackId))) + .getSingleOrNull(); + if (row == null) return false; + return File(row.path).existsSync(); + } + + /// Returns the local file path if cached, else null. + Future pathFor(String trackId) async { + final row = await (_db.select(_db.audioCacheIndex) + ..where((t) => t.trackId.equals(trackId))) + .getSingleOrNull(); + if (row == null) return null; + return File(row.path).existsSync() ? row.path : null; + } + + /// Downloads the track's stream to disk and indexes it. Idempotent — + /// returns the existing path if already cached. + Future pin(String trackId, {required CacheSource source}) async { + final existing = await pathFor(trackId); + if (existing != null) return existing; + + final dir = await _tracksDir(); + final path = '$dir/$trackId.mp3'; + final dio = await _dioFactory(); + try { + await dio.download('/api/tracks/$trackId/stream', path); + } catch (_) { + final f = File(path); + if (f.existsSync()) await f.delete(); + return null; + } + final size = await File(path).length(); + await _db.into(_db.audioCacheIndex).insertOnConflictUpdate( + AudioCacheIndexCompanion.insert( + trackId: trackId, + path: path, + sizeBytes: size, + source: source, + ), + ); + return path; + } + + /// Removes a track from the index AND deletes the file. + Future unpin(String trackId) async { + final row = await (_db.select(_db.audioCacheIndex) + ..where((t) => t.trackId.equals(trackId))) + .getSingleOrNull(); + if (row == null) return; + final f = File(row.path); + if (f.existsSync()) await f.delete(); + await (_db.delete(_db.audioCacheIndex) + ..where((t) => t.trackId.equals(trackId))) + .go(); + } + + /// Total bytes used by the cache. + Future usageBytes() async { + final result = await _db.customSelect( + 'SELECT COALESCE(SUM(size_bytes), 0) AS total FROM audio_cache_index', + readsFrom: {_db.audioCacheIndex}, + ).getSingle(); + return result.read('total'); + } + + /// Evicts files until usage ≤ targetBytes. Eviction order: + /// incidental → autoPrefetch → autoPlaylist → autoLiked. + /// `manual` never evicts via this path; only clearAll() removes them. + Future evict({required int targetBytes}) async { + final used = await usageBytes(); + if (used <= targetBytes) return; + final order = [ + CacheSource.incidental, + CacheSource.autoPrefetch, + CacheSource.autoPlaylist, + CacheSource.autoLiked, + ]; + int remaining = used - targetBytes; + for (final src in order) { + if (remaining <= 0) break; + final rows = await (_db.select(_db.audioCacheIndex) + ..where((t) => t.source.equalsValue(src)) + ..orderBy([(t) => OrderingTerm.asc(t.cachedAt)])) + .get(); + for (final row in rows) { + if (remaining <= 0) break; + final f = File(row.path); + if (f.existsSync()) await f.delete(); + await (_db.delete(_db.audioCacheIndex) + ..where((t) => t.trackId.equals(row.trackId))) + .go(); + remaining -= row.sizeBytes; + } + } + } + + /// Clears EVERY row + EVERY file. Wired to the operator's "Clear cache" + /// button — `manual` source rows are removed here but only here. + Future clearAll() async { + final dir = await _tracksDir(); + final d = Directory(dir); + if (d.existsSync()) { + for (final entity in d.listSync()) { + if (entity is File) await entity.delete(); + } + } + await _db.delete(_db.audioCacheIndex).go(); + } +} + +/// AppDb singleton. One per app run; ref.onDispose closes it. +final appDbProvider = Provider((ref) { + final db = AppDb(); + ref.onDispose(db.close); + return db; +}); + +final audioCacheManagerProvider = Provider((ref) { + final db = ref.watch(appDbProvider); + return AudioCacheManager( + db: db, + dioFactory: () async => ref.read(dioProvider.future), + ); +}); diff --git a/flutter_client/test/cache/audio_cache_manager_test.dart b/flutter_client/test/cache/audio_cache_manager_test.dart new file mode 100644 index 00000000..06f2cdee --- /dev/null +++ b/flutter_client/test/cache/audio_cache_manager_test.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:drift/native.dart' show NativeDatabase; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/cache/audio_cache_manager.dart'; +import 'package:minstrel/cache/db.dart'; + +AppDb _testDb() => AppDb(NativeDatabase.memory()); + +void main() { + test('isCached returns false when no row exists', () async { + final db = _testDb(); + addTearDown(db.close); + final mgr = AudioCacheManager( + db: db, + dioFactory: () async => Dio(), + cacheDirFactory: () async => Directory.systemTemp.createTempSync(), + ); + expect(await mgr.isCached('nonexistent'), false); + expect(await mgr.pathFor('nonexistent'), null); + }); + + test('usageBytes sums sizeBytes across rows', () async { + final db = _testDb(); + addTearDown(db.close); + final tmp = Directory.systemTemp.createTempSync(); + final mgr = AudioCacheManager( + db: db, + dioFactory: () async => Dio(), + cacheDirFactory: () async => tmp, + ); + await db.into(db.audioCacheIndex).insertAll([ + AudioCacheIndexCompanion.insert( + trackId: 'a', path: 'p', sizeBytes: 100, source: CacheSource.manual), + AudioCacheIndexCompanion.insert( + trackId: 'b', path: 'p', sizeBytes: 250, source: CacheSource.incidental), + ]); + expect(await mgr.usageBytes(), 350); + }); + + test('eviction order: incidental first, manual never', () async { + final db = _testDb(); + addTearDown(db.close); + final tmp = Directory.systemTemp.createTempSync(); + final mgr = AudioCacheManager( + db: db, + dioFactory: () async => Dio(), + cacheDirFactory: () async => tmp, + ); + final f1 = File('${tmp.path}/audio_cache/inc.mp3'); + await f1.create(recursive: true); + await f1.writeAsBytes(List.filled(100, 0)); + final f2 = File('${tmp.path}/audio_cache/man.mp3'); + await f2.create(recursive: true); + await f2.writeAsBytes(List.filled(100, 0)); + await db.into(db.audioCacheIndex).insertAll([ + AudioCacheIndexCompanion.insert( + trackId: 'inc', + path: f1.path, + sizeBytes: 100, + source: CacheSource.incidental), + AudioCacheIndexCompanion.insert( + trackId: 'man', + path: f2.path, + sizeBytes: 100, + source: CacheSource.manual), + ]); + expect(await mgr.usageBytes(), 200); + await mgr.evict(targetBytes: 100); + expect(await mgr.isCached('inc'), false); + expect(await mgr.isCached('man'), true); + }); + + test('clearAll removes everything including manual', () async { + final db = _testDb(); + addTearDown(db.close); + final tmp = Directory.systemTemp.createTempSync(); + final mgr = AudioCacheManager( + db: db, + dioFactory: () async => Dio(), + cacheDirFactory: () async => tmp, + ); + final f = File('${tmp.path}/audio_cache/man.mp3'); + await f.create(recursive: true); + await f.writeAsBytes(List.filled(50, 0)); + await db.into(db.audioCacheIndex).insertOnConflictUpdate( + AudioCacheIndexCompanion.insert( + trackId: 'man', + path: f.path, + sizeBytes: 50, + source: CacheSource.manual), + ); + expect(await mgr.usageBytes(), 50); + await mgr.clearAll(); + expect(await mgr.usageBytes(), 0); + expect(await mgr.isCached('man'), false); + }); + + test('unpin removes index row + deletes file', () async { + final db = _testDb(); + addTearDown(db.close); + final tmp = Directory.systemTemp.createTempSync(); + final mgr = AudioCacheManager( + db: db, + dioFactory: () async => Dio(), + cacheDirFactory: () async => tmp, + ); + final f = File('${tmp.path}/audio_cache/x.mp3'); + await f.create(recursive: true); + await f.writeAsBytes(List.filled(10, 0)); + await db.into(db.audioCacheIndex).insertOnConflictUpdate( + AudioCacheIndexCompanion.insert( + trackId: 'x', + path: f.path, + sizeBytes: 10, + source: CacheSource.autoPrefetch), + ); + expect(await mgr.isCached('x'), true); + await mgr.unpin('x'); + expect(await mgr.isCached('x'), false); + expect(f.existsSync(), false); + }); +}