refactor(analysis): move the analysis disk cache into audio_analysis_cache.dart

The card owned the bundle storage (key derivation, {key}.json,
{key}.png and {key}_ch{n}.png, the shared trim budget) as static
members, and the cache page spelled the audio_analysis_cache directory
literal twice more.

Move the storage verbatim into AudioAnalysisDiskCache next to the
existing lease/trim policy. AudioAnalysisDiskCache.directoryIn is now
the only place that names the directory; the card's cacheDir and both
cache page call sites use it, with the page keeping its non-creating
behavior and await order. loadFromCache takes the card's network input
as a parameter instead of reading the field.

The 63-bit FNV-1a cache key stays local: lib/utils/hash_utils.dart
fnv1a32 uses a different basis, prime and width, so reusing it would
rename every cached bundle on devices.
This commit is contained in:
zarzet committed 2026-10-19 00:59:24 +07:00
1 parent 34a004f96f
commit b575a4ea38
3 files changed
+203 -158

No files matched your search

@@ -12,6 +12,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/audio_analysis_cache.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
import 'package:spotiflac_android/services/motion_artwork_store.dart';
@@ -96,7 +97,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
Directory('${appSupportDir.path}/library_covers'),
);
final audioAnalysisStatsFuture = _scanDirectory(
Directory('${appSupportDir.path}/audio_analysis_cache'),
AudioAnalysisDiskCache.directoryIn(appSupportDir.path),
);
final prefs = await prefsFuture;
@@ -216,7 +217,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
Future<void> _clearAudioAnalysisCache() async {
final appSupportDir = await getApplicationSupportDirectory();
final analysisDir = Directory('${appSupportDir.path}/audio_analysis_cache');
final analysisDir = AudioAnalysisDiskCache.directoryIn(appSupportDir.path);
await _clearDirectoryContents(analysisDir.path);
}
+158
View File
@@ -1,4 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/audio_analysis_models.dart';
import 'package:spotiflac_android/services/network_audio_analysis_input.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
/// On-disk analysis bundles: `{key}.json`, `{key}.png` and `{key}_ch{n}.png`
/// in `audio_analysis_cache` under the application support directory. The
/// directory name, key derivation and file names are persisted on devices.
abstract final class AudioAnalysisDiskCache {
/// The one place that names the cache directory; it does not create it.
static Directory directoryIn(String appSupportPath) =>
Directory('$appSupportPath/audio_analysis_cache');
static Future<void> clearCache(String filePath) async {
try {
final dir = await cacheDir();
final key = cacheKey(filePath);
final jsonFile = File('${dir.path}/$key.json');
if (await jsonFile.exists()) {
await jsonFile.delete();
}
await for (final entity in dir.list()) {
if (entity is! File) continue;
final name = entity.path.replaceAll('\\', '/').split('/').last;
final isCombined = name == '$key.png';
final isChannel = name.startsWith('${key}_ch') && name.endsWith('.png');
if (isCombined || isChannel) {
await entity.delete();
}
}
} catch (_) {}
}
static String cacheKey(String filePath) {
var hash = 0xcbf29ce484222325;
for (final byte in utf8.encode(filePath)) {
hash ^= byte;
hash = (hash * 0x100000001b3) & 0x7FFFFFFFFFFFFFFF;
}
return hash.toRadixString(16);
}
static Future<Directory> cacheDir() async {
final appSupport = await getApplicationSupportDirectory();
final dir = directoryIn(appSupport.path);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
static final cacheBudget = AudioAnalysisCache();
static Future<void> trimCache() async {
try {
await cacheBudget.trim(await cacheDir());
} catch (_) {
// Cache maintenance must not interrupt the selected track's analysis.
}
}
static Future<AudioAnalysisData?> loadFromCache(
String filePath,
NetworkAudioAnalysisInput networkInput,
) async {
try {
final dir = await cacheDir();
final key = cacheKey(filePath);
final file = File('${dir.path}/$key.json');
if (!await file.exists()) return null;
final json = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map,
);
if (json['cacheVersion'] != AudioAnalysisData.cacheVersion) {
return null;
}
final cachedSize = json['fileSize'] as int;
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
final currentSize = await networkInput.length(filePath);
if (currentSize <= 0 || currentSize != cachedSize) return null;
} else if (!filePath.startsWith('content://')) {
final currentSize = await File(filePath).length();
if (currentSize != cachedSize) return null;
} else {
final stat = await PlatformBridge.safStat(filePath);
final currentSize = (stat['size'] as num?)?.toInt() ?? 0;
if (currentSize > 0 && currentSize != cachedSize) return null;
}
final data = AudioAnalysisData.fromJson(json);
await file.setLastModified(DateTime.now());
return data;
} catch (_) {
return null;
}
}
static Future<void> saveToCache(
String filePath,
AudioAnalysisData data,
) async {
try {
final dir = await cacheDir();
final key = cacheKey(filePath);
final file = File('${dir.path}/$key.json');
await file.writeAsString(jsonEncode(data.toJson()));
} catch (_) {}
}
static Future<void> saveSpectrogramToCache(
String filePath,
ui.Image image, {
required int channel,
}) async {
try {
final dir = await cacheDir();
final key = cacheKey(filePath);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
final file = File(
'${dir.path}/${_spectrogramCacheFileName(key, channel)}',
);
await file.writeAsBytes(byteData.buffer.asUint8List());
await cacheBudget.trim(dir);
}
} catch (_) {}
}
static String _spectrogramCacheFileName(String key, int channel) =>
channel < 0 ? '$key.png' : '${key}_ch$channel.png';
static Future<ui.Image?> loadSpectrogramFromCache(
String filePath, {
required int channel,
}) async {
try {
final dir = await cacheDir();
final key = cacheKey(filePath);
final file = File(
'${dir.path}/${_spectrogramCacheFileName(key, channel)}',
);
if (!await file.exists()) return null;
final bytes = await file.readAsBytes();
final completer = Completer<ui.Image>();
ui.decodeImageFromList(bytes, completer.complete);
return await completer.future;
} catch (_) {
return null;
}
}
}
/// Evicts complete analysis bundles (metadata, combined image and channels).
/// Leases protect cache reads/writes while a card changes tracks or channels.
+42 -156
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
@@ -172,18 +171,23 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
Future<void> _tryLoadFromCache() async {
final expectedPath = widget.filePath;
final requestId = _spectrogramRequestId;
final releaseCache = _cacheBudget.retain(_cacheKey(expectedPath));
final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain(
AudioAnalysisDiskCache.cacheKey(expectedPath),
);
bool isCurrentRequest() =>
mounted &&
widget.filePath == expectedPath &&
requestId == _spectrogramRequestId;
try {
final cached = await _loadFromCache(expectedPath);
final cached = await AudioAnalysisDiskCache.loadFromCache(
expectedPath,
_networkInput,
);
if (cached != null && isCurrentRequest()) {
final unsupported = unsupportedAudioAnalysisCodecLabel(cached.codec);
if (unsupported != null) {
await _clearCache(expectedPath);
await AudioAnalysisDiskCache.clearCache(expectedPath);
if (isCurrentRequest()) {
setState(() {
_unsupportedCodec = unsupported;
@@ -196,7 +200,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
_data = cached;
_checkingCache = false;
});
var image = await _loadSpectrogramFromCache(
var image = await AudioAnalysisDiskCache.loadSpectrogramFromCache(
expectedPath,
channel: _spectrogramChannel,
);
@@ -223,7 +227,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
// Missing or unreadable cache entries fall back to the Analyze action.
} finally {
releaseCache();
unawaited(_trimCache());
unawaited(AudioAnalysisDiskCache.trimCache());
}
if (isCurrentRequest()) {
setState(() => _checkingCache = false);
@@ -247,14 +251,20 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
sampleRate: data?.sampleRate,
channels: data?.channels,
);
await _saveSpectrogramToCache(sourcePath, artifact.image, channel: channel);
await AudioAnalysisDiskCache.saveSpectrogramToCache(
sourcePath,
artifact.image,
channel: channel,
);
return artifact.image;
}
Future<void> _analyze({bool forceRefresh = false}) async {
if (_analyzing) return;
final sourcePath = widget.filePath;
final releaseCache = _cacheBudget.retain(_cacheKey(sourcePath));
final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain(
AudioAnalysisDiskCache.cacheKey(sourcePath),
);
final requestId = ++_spectrogramRequestId;
_analysisJobs.invalidate();
_networkInput.cancel();
@@ -273,10 +283,15 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
try {
if (forceRefresh) {
await _clearCache(sourcePath);
await AudioAnalysisDiskCache.clearCache(sourcePath);
}
final cached = forceRefresh ? null : await _loadFromCache(sourcePath);
final cached = forceRefresh
? null
: await AudioAnalysisDiskCache.loadFromCache(
sourcePath,
_networkInput,
);
_checkAnalysisRequest(requestId);
AudioAnalysisData data;
ui.Image? image;
@@ -287,7 +302,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
throw UnsupportedAudioAnalysisCodecException(unsupported);
}
data = cached;
image = await _loadSpectrogramFromCache(
image = await AudioAnalysisDiskCache.loadSpectrogramFromCache(
sourcePath,
channel: _spectrogramChannel,
);
@@ -295,8 +310,8 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
final result = await _runAnalysis(sourcePath, requestId);
data = result.data;
image = result.spectrogramImage;
await _saveToCache(sourcePath, data);
await _saveSpectrogramToCache(
await AudioAnalysisDiskCache.saveToCache(sourcePath, data);
await AudioAnalysisDiskCache.saveSpectrogramToCache(
sourcePath,
image,
channel: _spectrogramChannel,
@@ -338,145 +353,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
}
} finally {
releaseCache();
unawaited(_trimCache());
}
}
static Future<void> _clearCache(String filePath) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
final jsonFile = File('${dir.path}/$key.json');
if (await jsonFile.exists()) {
await jsonFile.delete();
}
await for (final entity in dir.list()) {
if (entity is! File) continue;
final name = entity.path.replaceAll('\\', '/').split('/').last;
final isCombined = name == '$key.png';
final isChannel = name.startsWith('${key}_ch') && name.endsWith('.png');
if (isCombined || isChannel) {
await entity.delete();
}
}
} catch (_) {}
}
static String _cacheKey(String filePath) {
var hash = 0xcbf29ce484222325;
for (final byte in utf8.encode(filePath)) {
hash ^= byte;
hash = (hash * 0x100000001b3) & 0x7FFFFFFFFFFFFFFF;
}
return hash.toRadixString(16);
}
static Future<Directory> _cacheDir() async {
final appSupport = await getApplicationSupportDirectory();
final dir = Directory('${appSupport.path}/audio_analysis_cache');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
static final _cacheBudget = AudioAnalysisCache();
static Future<void> _trimCache() async {
try {
await _cacheBudget.trim(await _cacheDir());
} catch (_) {
// Cache maintenance must not interrupt the selected track's analysis.
}
}
Future<AudioAnalysisData?> _loadFromCache(String filePath) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
final file = File('${dir.path}/$key.json');
if (!await file.exists()) return null;
final json = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map,
);
if (json['cacheVersion'] != AudioAnalysisData.cacheVersion) {
return null;
}
final cachedSize = json['fileSize'] as int;
if (NetworkAudioAnalysisInput.isNetwork(filePath)) {
final currentSize = await _networkInput.length(filePath);
if (currentSize <= 0 || currentSize != cachedSize) return null;
} else if (!filePath.startsWith('content://')) {
final currentSize = await File(filePath).length();
if (currentSize != cachedSize) return null;
} else {
final stat = await PlatformBridge.safStat(filePath);
final currentSize = (stat['size'] as num?)?.toInt() ?? 0;
if (currentSize > 0 && currentSize != cachedSize) return null;
}
final data = AudioAnalysisData.fromJson(json);
await file.setLastModified(DateTime.now());
return data;
} catch (_) {
return null;
}
}
static Future<void> _saveToCache(
String filePath,
AudioAnalysisData data,
) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
final file = File('${dir.path}/$key.json');
await file.writeAsString(jsonEncode(data.toJson()));
} catch (_) {}
}
static Future<void> _saveSpectrogramToCache(
String filePath,
ui.Image image, {
required int channel,
}) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData != null) {
final file = File(
'${dir.path}/${_spectrogramCacheFileName(key, channel)}',
);
await file.writeAsBytes(byteData.buffer.asUint8List());
await _cacheBudget.trim(dir);
}
} catch (_) {}
}
static String _spectrogramCacheFileName(String key, int channel) =>
channel < 0 ? '$key.png' : '${key}_ch$channel.png';
static Future<ui.Image?> _loadSpectrogramFromCache(
String filePath, {
required int channel,
}) async {
try {
final dir = await _cacheDir();
final key = _cacheKey(filePath);
final file = File(
'${dir.path}/${_spectrogramCacheFileName(key, channel)}',
);
if (!await file.exists()) return null;
final bytes = await file.readAsBytes();
final completer = Completer<ui.Image>();
ui.decodeImageFromList(bytes, completer.complete);
return await completer.future;
} catch (_) {
return null;
unawaited(AudioAnalysisDiskCache.trimCache());
}
}
@@ -753,7 +630,9 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
final previousChannel = _spectrogramChannel;
final sourcePath = widget.filePath;
final releaseCache = _cacheBudget.retain(_cacheKey(sourcePath));
final releaseCache = AudioAnalysisDiskCache.cacheBudget.retain(
AudioAnalysisDiskCache.cacheKey(sourcePath),
);
final requestId = ++_spectrogramRequestId;
setState(() {
_spectrogramChannel = channel;
@@ -762,7 +641,10 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
ui.Image? image;
try {
image = await _loadSpectrogramFromCache(sourcePath, channel: channel);
image = await AudioAnalysisDiskCache.loadSpectrogramFromCache(
sourcePath,
channel: channel,
);
if (image == null) {
final artifact = await _generateSpectrogramForFile(
sourcePath,
@@ -773,7 +655,11 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
channels: data.channels,
);
image = artifact.image;
await _saveSpectrogramToCache(sourcePath, image, channel: channel);
await AudioAnalysisDiskCache.saveSpectrogramToCache(
sourcePath,
image,
channel: channel,
);
}
if (!mounted || requestId != _spectrogramRequestId) {
@@ -795,7 +681,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
}
} finally {
releaseCache();
unawaited(_trimCache());
unawaited(AudioAnalysisDiskCache.trimCache());
}
}