mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
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:
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user