Files
SpotiFLAC-Mobile/lib/services/network_metadata_service.dart
T
zarzet 927fd033cd perf(nas): process listings, artwork caches, and upload recovery natively
Move WebDAV/HTML parsing, content-addressed artwork caching, cover promotion, and streamed upload verification to native workers. Preserve proxy authentication, cancellation, cache limits, and deep XML fallback behavior.
2026-10-06 23:27:07 +07:00

163 lines
5.8 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,
}) : _storage = storage ?? NetworkStorageService.instance,
_useNativeReader = reader == null,
_reader =
reader ??
((url, name) =>
PlatformBridge.readFileMetadata(url, displayName: name)),
_directory = cacheDirectory ?? getTemporaryDirectory;
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>>>{};
Future<void> _tail = Future.value();
Future<Map<String, dynamic>> read(
String source, {
bool forceRefresh = false,
}) {
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'));
}
final operation = _tail.then((_) => _load(source));
_tail = operation.then<void>((_) {}, onError: (Object _, StackTrace _) {});
final result = operation.whenComplete(() {
_pending.remove(source);
});
_pending[source] = result;
return result;
}
Future<Map<String, dynamic>> _load(String source) async {
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);
Map<String, dynamic>? loaded;
if (_useNativeReader) {
try {
loaded = await PlatformBridge.runNativeDataJob({
'operation': 'network_metadata',
'url': url,
'display_name': name,
'cache_directory': '${(await _directory()).path}/network_metadata',
});
} on MissingPluginException {
// Test/unsupported hosts retain the same reader on an isolate.
}
}
if (loaded == null) {
final tags = await _reader(url, name);
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,
});
}
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,
};
}