Files
SpotiFLAC-Mobile/lib/services/network_metadata_service.dart
T
zarzet 5eafd3acc4 perf(library): prefetch network metadata with bounded concurrency
Read up to three WebDAV tracks or two SMB tracks through a scan-owned metadata service while retaining listing order, bounded output, artwork and progress. Keep pause and cancellation independent of playback and add host regressions plus a native Android latency benchmark.
2026-10-10 23:22:41 +07:00

207 lines
7.2 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
typedef NetworkTagReader =
Future<Map<String, dynamic>> Function(String url, String name);
/// One bounded native tag read per source, shared by playback and Lyrics.
/// Only the selected track is inspected; browsing does not scan the NAS.
class NetworkMetadataService {
NetworkMetadataService({
NetworkStorageService? storage,
NetworkTagReader? reader,
Future<Directory> Function()? cacheDirectory,
int maxConcurrentReads = 1,
}) : _storage = storage ?? NetworkStorageService.instance,
_useNativeReader = reader == null,
_reader =
reader ??
((url, name) =>
PlatformBridge.readFileMetadata(url, displayName: name)),
_directory = cacheDirectory ?? getTemporaryDirectory,
_lanes = List.generate(
RangeError.checkValueInInterval(maxConcurrentReads, 1, 4),
(_) => (tail: Future<void>.value(), pending: 0),
);
static final instance = NetworkMetadataService();
final NetworkStorageService _storage;
final NetworkTagReader _reader;
final bool _useNativeReader;
final Future<Directory> Function() _directory;
final _cache = <String, ({DateTime time, Map<String, dynamic> value})>{};
final _pending = <String, Future<Map<String, dynamic>>>{};
final List<({Future<void> tail, int pending})> _lanes;
final _requestIds = <String>{};
bool _stopped = false;
Future<Map<String, dynamic>> read(
String source, {
bool forceRefresh = false,
}) {
if (_stopped) {
return Future.error(StateError('Network metadata reader stopped'));
}
if (forceRefresh) _cache.remove(source);
final cached = _cache[source];
if (cached != null &&
DateTime.now().difference(cached.time) < const Duration(minutes: 5)) {
return Future.value(cached.value);
}
final pending = _pending[source];
if (pending != null) return pending;
if (_pending.length >= 4) {
return Future.error(StateError('Network metadata reader is busy'));
}
var lane = 0;
for (var i = 1; i < _lanes.length; i++) {
if (_lanes[i].pending < _lanes[lane].pending) lane = i;
}
final requestId = 'network_metadata_${NetworkStorageService.newId()}';
_requestIds.add(requestId);
final operation = _lanes[lane].tail.then((_) => _load(source, requestId));
_lanes[lane] = (
tail: operation.then<void>((_) {}, onError: (Object _, StackTrace _) {}),
pending: _lanes[lane].pending + 1,
);
final result = operation.whenComplete(() {
_pending.remove(source);
_requestIds.remove(requestId);
_lanes[lane] = (
tail: _lanes[lane].tail,
pending: _lanes[lane].pending - 1,
);
});
_pending[source] = result;
return result;
}
/// Scan-owned readers can stop their work without cancelling playback reads.
Future<void> cancelPending() async {
_stopped = true;
await Future.wait([
for (final id in _requestIds.toList())
PlatformBridge.cancelNativeDataJob(id).catchError((Object _) {}),
]);
}
void _checkStopped() {
if (_stopped) throw StateError('Network metadata reader stopped');
}
Future<Map<String, dynamic>> _load(String source, String requestId) async {
_checkStopped();
final uri = Uri.parse(source);
if (uri.scheme != 'network') {
throw const FormatException('Expected a network source');
}
final connection = await _storage.connection(uri.host);
final path = uri.pathSegments.join('/');
var name =
(path.isEmpty
? connection.root.pathSegments.lastOrNull
: uri.pathSegments.lastOrNull) ??
'';
if (name.toLowerCase().endsWith('.alac')) {
name = '${name.substring(0, name.length - 5)}.m4a';
}
final url = await _storage.resolve(source);
_checkStopped();
Map<String, dynamic>? loaded;
if (_useNativeReader) {
try {
final directory = await _directory();
_checkStopped();
loaded = await PlatformBridge.runNativeDataJob({
'operation': 'network_metadata',
'url': url,
'display_name': name,
'cache_directory': '${directory.path}/network_metadata',
}, requestId: requestId);
} on MissingPluginException {
// Test/unsupported hosts retain the same reader on an isolate.
}
}
if (loaded == null) {
_checkStopped();
final tags = await _reader(url, name);
_checkStopped();
String directory = '';
if (tags['cover_base64'] is String &&
(tags['cover_base64'] as String).isNotEmpty) {
try {
directory = '${(await _directory()).path}/network_metadata';
} catch (_) {}
}
loaded = await compute(_cacheNetworkMetadata, {
'source': source,
'directory': directory,
'metadata': tags,
});
}
_checkStopped();
final result = loaded ?? (throw StateError('Network metadata is empty'));
final metadata = Map<String, dynamic>.from(result['metadata'] as Map);
if (metadata['error']?.toString().isNotEmpty == true) {
throw StateError('Network metadata could not be read');
}
final value = Map<String, dynamic>.unmodifiable(metadata);
if (result['cacheable'] != true) return value;
_cache.remove(source);
while (_cache.length >= 32) {
_cache.remove(_cache.keys.first);
}
_cache[source] = (time: DateTime.now(), value: value);
return value;
}
}
/// Compatibility path keeps cover decoding/hash/directory sorting off the UI
/// isolate too. Production receives only native file paths and small tags.
Future<Map<String, dynamic>> _cacheNetworkMetadata(
Map<String, dynamic> job,
) async {
final metadata = Map<String, dynamic>.from(job['metadata'] as Map);
final cover = metadata.remove('cover_base64');
if (cover is String &&
cover.isNotEmpty &&
(job['directory'] as String).isNotEmpty &&
cover.length <= 6 * 1024 * 1024) {
// A cover/cache failure must not discard successfully read tags or lyrics.
try {
final directory = Directory(job['directory'] as String);
await directory.create(recursive: true);
final file = File(
'${directory.path}/${sha256.convert(utf8.encode(job['source'] as String))}.image',
);
await file.writeAsBytes(base64Decode(cover), flush: true);
metadata['cover_path'] = file.path;
final files = await directory
.list()
.where((entry) => entry is File)
.cast<File>()
.toList();
final dated = <({File file, DateTime time})>[];
for (final entry in files) {
dated.add((file: entry, time: (await entry.stat()).modified));
}
dated.sort((a, b) => b.time.compareTo(a.time));
for (final old in dated.skip(32)) {
await old.file.delete();
}
} catch (_) {}
}
return {
'metadata': metadata,
'cacheable': utf8.encode(jsonEncode(metadata)).length <= 128 * 1024,
};
}