diff --git a/flutter_client/lib/player/album_cover_cache.dart b/flutter_client/lib/player/album_cover_cache.dart new file mode 100644 index 00000000..4ddd1270 --- /dev/null +++ b/flutter_client/lib/player/album_cover_cache.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Caches album cover bytes to disk so MediaItem.artUri can point at a +/// file:// URI. Android's MediaSession framework fetches artUri itself +/// and doesn't carry our Bearer header, so we pre-fetch via the +/// authenticated dio and hand the system a local file path instead. +/// +/// Cache layout: /album_covers/.jpg. +/// No explicit eviction — covers are tiny and OS clears app cache when +/// space is tight. +class AlbumCoverCache { + AlbumCoverCache({ + required Future Function() dioFactory, + Future Function()? cacheDirFactory, + }) : _dioFactory = dioFactory, + _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory; + + final Future Function() _dioFactory; + final Future Function() _cacheDirFactory; + + /// In-flight requests keyed by albumId so concurrent callers for the + /// same album dedupe to one fetch. + final Map> _inflight = {}; + + /// Returns local file path to the album cover, or null on any + /// failure (network, 4xx/5xx, disk full, empty albumId). + Future getOrFetch(String albumId) { + if (albumId.isEmpty) return Future.value(null); + final existing = _inflight[albumId]; + if (existing != null) return existing; + final fut = _doFetch(albumId); + _inflight[albumId] = fut; + fut.whenComplete(() => _inflight.remove(albumId)); + return fut; + } + + Future _doFetch(String albumId) async { + try { + final dir = await _cacheDirFactory(); + final coversDir = Directory('${dir.path}/album_covers'); + await coversDir.create(recursive: true); + final filePath = '${coversDir.path}/$albumId.jpg'; + final file = File(filePath); + if (await file.exists()) return filePath; + + final dio = await _dioFactory(); + final r = await dio.get>( + '/api/albums/$albumId/cover', + options: Options(responseType: ResponseType.bytes), + ); + final bytes = r.data; + if (bytes == null || bytes.isEmpty) return null; + await file.writeAsBytes(bytes, flush: true); + return filePath; + } catch (e) { + debugPrint('AlbumCoverCache: fetch failed for $albumId: $e'); + return null; + } + } +} diff --git a/flutter_client/test/player/album_cover_cache_test.dart b/flutter_client/test/player/album_cover_cache_test.dart new file mode 100644 index 00000000..79940f89 --- /dev/null +++ b/flutter_client/test/player/album_cover_cache_test.dart @@ -0,0 +1,121 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/player/album_cover_cache.dart'; + +class _RecordingAdapter implements HttpClientAdapter { + _RecordingAdapter(this.body); + final List body; + int callCount = 0; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + callCount++; + return ResponseBody.fromBytes(body, 200, headers: { + Headers.contentTypeHeader: ['image/jpeg'], + }); + } + + @override + void close({bool force = false}) {} +} + +class _FailingAdapter implements HttpClientAdapter { + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + message: 'simulated network failure', + ); + } + + @override + void close({bool force = false}) {} +} + +Future _tmpDirFactory() async => + Directory.systemTemp.createTempSync('cover_cache_test_'); + +void main() { + test('cache miss writes file and returns path', () async { + final adapter = _RecordingAdapter([1, 2, 3, 4]); + final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; + final cache = AlbumCoverCache( + dioFactory: () async => dio, + cacheDirFactory: _tmpDirFactory, + ); + final path = await cache.getOrFetch('alb-1'); + expect(path, isNotNull); + expect(File(path!).readAsBytesSync(), [1, 2, 3, 4]); + expect(adapter.callCount, 1); + }); + + test('cache hit returns same path without re-fetching', () async { + final adapter = _RecordingAdapter([9, 9, 9]); + final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; + final tmp = await _tmpDirFactory(); + final cache = AlbumCoverCache( + dioFactory: () async => dio, + cacheDirFactory: () async => tmp, + ); + final p1 = await cache.getOrFetch('alb-2'); + final p2 = await cache.getOrFetch('alb-2'); + expect(p1, p2); + expect(adapter.callCount, 1); + }); + + test('concurrent calls for same albumId dedupe', () async { + final adapter = _RecordingAdapter([1, 2, 3]); + final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; + final cache = AlbumCoverCache( + dioFactory: () async => dio, + cacheDirFactory: _tmpDirFactory, + ); + final results = await Future.wait([ + cache.getOrFetch('alb-3'), + cache.getOrFetch('alb-3'), + cache.getOrFetch('alb-3'), + ]); + expect(results[0], isNotNull); + expect(results[1], results[0]); + expect(results[2], results[0]); + expect(adapter.callCount, 1); + }); + + test('failure returns null without writing file', () async { + final adapter = _FailingAdapter(); + final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; + final tmp = await _tmpDirFactory(); + final cache = AlbumCoverCache( + dioFactory: () async => dio, + cacheDirFactory: () async => tmp, + ); + final path = await cache.getOrFetch('alb-4'); + expect(path, isNull); + expect(File('${tmp.path}/album_covers/alb-4.jpg').existsSync(), isFalse); + }); + + test('empty albumId returns null without dio call', () async { + final adapter = _RecordingAdapter([1, 2]); + final dio = Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter; + final cache = AlbumCoverCache( + dioFactory: () async => dio, + cacheDirFactory: _tmpDirFactory, + ); + final path = await cache.getOrFetch(''); + expect(path, isNull); + expect(adapter.callCount, 0); + }); +}