mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
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.
238 lines
7.7 KiB
Dart
238 lines
7.7 KiB
Dart
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.
|
|
class AudioAnalysisCache {
|
|
final Map<String, int> _active = {};
|
|
Future<void> _sweep = Future<void>.value();
|
|
static final _filename = RegExp(r'^([a-f0-9]+)(?:_ch\d+)?\.(json|png)$');
|
|
|
|
void Function() retain(String key) {
|
|
_active.update(key, (count) => count + 1, ifAbsent: () => 1);
|
|
var released = false;
|
|
return () {
|
|
if (released) return;
|
|
released = true;
|
|
final count = _active[key]! - 1;
|
|
if (count == 0) {
|
|
_active.remove(key);
|
|
} else {
|
|
_active[key] = count;
|
|
}
|
|
};
|
|
}
|
|
|
|
Future<void> trim(
|
|
Directory directory, {
|
|
int maxBytes = 64 * 1024 * 1024,
|
|
int targetBytes = 48 * 1024 * 1024,
|
|
}) {
|
|
assert(targetBytes >= 0 && targetBytes <= maxBytes);
|
|
_sweep = _sweep.then((_) async {
|
|
try {
|
|
if (!await directory.exists()) return;
|
|
final bundles = <String, _AnalysisBundle>{};
|
|
var total = 0;
|
|
await for (final file in directory.list(followLinks: false)) {
|
|
if (file is! File) continue;
|
|
final match = _filename.firstMatch(file.uri.pathSegments.last);
|
|
if (match == null) continue;
|
|
final stat = await file.stat();
|
|
if (stat.type != FileSystemEntityType.file) continue;
|
|
final bundle = bundles.putIfAbsent(match[1]!, _AnalysisBundle.new);
|
|
bundle.files.add(file);
|
|
if (bundle.modified.isBefore(stat.modified)) {
|
|
bundle.modified = stat.modified;
|
|
}
|
|
total += stat.size;
|
|
}
|
|
if (total <= maxBytes) return;
|
|
final oldest = bundles.entries.toList()
|
|
..sort((a, b) => a.value.modified.compareTo(b.value.modified));
|
|
for (final entry in oldest) {
|
|
if (total <= targetBytes) break;
|
|
if (_active.containsKey(entry.key)) continue;
|
|
for (final file in entry.value.files) {
|
|
// A cache reader can acquire a lease during the asynchronous scan.
|
|
if (_active.containsKey(entry.key)) break;
|
|
try {
|
|
final size = await file.length();
|
|
if (_active.containsKey(entry.key)) break;
|
|
await file.delete();
|
|
total -= size;
|
|
} on FileSystemException {
|
|
// Cache cleanup or a metadata refresh can race with this sweep.
|
|
}
|
|
}
|
|
}
|
|
} on FileSystemException {
|
|
// Optional cache maintenance must not fail audio analysis.
|
|
}
|
|
});
|
|
return _sweep;
|
|
}
|
|
}
|
|
|
|
class _AnalysisBundle {
|
|
final List<File> files = [];
|
|
DateTime modified = DateTime.fromMillisecondsSinceEpoch(0);
|
|
}
|