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.
This commit is contained in:
zarzet committed 2026-10-06 23:27:07 +07:00
1 parent 867371cb67
commit 927fd033cd
15 files changed
+1349 -98

No files matched your search

+37 -20
View File
@@ -1,7 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/network_metadata_service.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
@@ -119,28 +121,28 @@ class NetworkLibraryScanner {
await File(cover).exists()) {
// Identical embedded artwork shares an immutable file, including
// across albums/sources. Cleanup retains all DB cover references.
File? target;
File? staged;
try {
final digest = await sha256.bind(File(cover).openRead()).first;
target = File('${covers.path}/network_cover_$digest.image');
staged = File(
'${target.path}.${NetworkStorageService.newId()}.tmp',
);
if (!await target.exists() || await target.length() == 0) {
await File(cover).copy(staged.path);
await staged.rename(target.path);
try {
if (!PlatformBridge.supportsCoreBackend) {
final directory = covers.path;
coverPath = await Isolate.run(
() => _promoteCover(cover, directory),
);
} else {
final promoted = await PlatformBridge.runNativeDataJob({
'operation': 'promote_network_cover',
'path': cover,
'directory': covers.path,
});
coverPath = promoted['cover_path'] as String;
}
} on MissingPluginException {
final directory = covers.path;
coverPath = await Isolate.run(
() => _promoteCover(cover, directory),
);
}
coverPath = target.path;
} catch (_) {
if (target != null && await target.exists()) {
coverPath = target.path;
}
} finally {
if (staged != null && await staged.exists()) {
await staged.delete();
}
}
} catch (_) {}
}
await checkpoint();
final row = networkLibraryRow(trackSource, entry, tags)
@@ -173,6 +175,21 @@ class NetworkLibraryScanner {
}
}
Future<String?> _promoteCover(String cover, String directory) async {
final digest = await sha256.bind(File(cover).openRead()).first;
final target = File('$directory/network_cover_$digest.image');
final staged = File('${target.path}.${NetworkStorageService.newId()}.tmp');
try {
if (!await target.exists() || await target.length() == 0) {
await File(cover).copy(staged.path);
await staged.rename(target.path);
}
return target.path;
} finally {
if (await staged.exists()) await staged.delete();
}
}
Map<String, dynamic> networkLibraryRow(
String source,
NetworkEntry entry,
+76 -34
View File
@@ -3,6 +3,8 @@ 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';
@@ -18,6 +20,7 @@ class NetworkMetadataService {
NetworkTagReader? reader,
Future<Directory> Function()? cacheDirectory,
}) : _storage = storage ?? NetworkStorageService.instance,
_useNativeReader = reader == null,
_reader =
reader ??
((url, name) =>
@@ -27,6 +30,7 @@ class NetworkMetadataService {
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>>>{};
@@ -72,44 +76,41 @@ class NetworkMetadataService {
name = '${name.substring(0, name.length - 5)}.m4a';
}
final url = await _storage.resolve(source);
final metadata = Map<String, dynamic>.from(await _reader(url, name));
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 cover = metadata.remove('cover_base64');
if (cover is String &&
cover.isNotEmpty &&
cover.length <= 6 * 1024 * 1024) {
// A cover/cache failure must not discard successfully read tags or lyrics.
try {
final directory = Directory(
'${(await _directory()).path}/network_metadata',
);
await directory.create(recursive: true);
final file = File(
'${directory.path}/${sha256.convert(utf8.encode(source))}.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 (_) {}
}
final value = Map<String, dynamic>.unmodifiable(metadata);
// Oversized tag blocks can still be displayed for the current song, but
// must not accumulate across a large NAS collection in the memory cache.
if (utf8.encode(jsonEncode(value)).length > 128 * 1024) return value;
if (result['cacheable'] != true) return value;
_cache.remove(source);
while (_cache.length >= 32) {
_cache.remove(_cache.keys.first);
@@ -118,3 +119,44 @@ class NetworkMetadataService {
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,
};
}
+66 -5
View File
@@ -5,14 +5,44 @@ import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:html/parser.dart' as html;
import 'package:spotiflac_android/services/network_certificate.dart';
import 'package:spotiflac_android/services/network_smb_client.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:xml/xml.dart';
part 'network_storage_upload.dart';
List<NetworkEntry> _parseNetworkListingInBackground(
Map<String, dynamic> request,
) {
final connection = NetworkConnection(
id: 'listing',
name: 'listing',
protocol: NetworkProtocol.values.byName(request['protocol'] as String),
address: request['address'] as String,
);
return NetworkStorageService.parseListing(
connection,
request['path'] as String,
utf8.decode(request['bytes'] as Uint8List, allowMalformed: true),
);
}
List<NetworkEntry> _hydrateNetworkEntriesInBackground(List<dynamic> entries) =>
[
for (final raw in entries)
NetworkEntry(
(raw as Map)['path'] as String,
raw['name'] as String,
directory: raw['directory'] as bool,
size: (raw['size'] as num?)?.toInt(),
),
];
enum NetworkProtocol { smb, webdav, http }
class NetworkConnection {
@@ -470,11 +500,7 @@ class NetworkStorageService {
}
bytes.add(chunk);
}
return parseListing(
c,
path,
utf8.decode(bytes.takeBytes(), allowMalformed: true),
);
return await parseListingInBackground(c, path, bytes.takeBytes());
} finally {
client.close(force: true);
}
@@ -570,6 +596,41 @@ class NetworkStorageService {
return _sorted(found.values.toList());
}
/// Keep DOM parsing and sorting away from the Flutter frame while retaining
/// the synchronous parser for unsupported native hosts and contract tests.
static Future<List<NetworkEntry>> parseListingInBackground(
NetworkConnection connection,
String path,
Uint8List bytes,
) async {
List<dynamic> entries;
try {
final result = await PlatformBridge.runNativeDataJob({
'operation': 'parse_network_listing',
'protocol': connection.protocol.name,
'base_url': networkTarget(connection, path).toString(),
'path': path,
}, bytes: bytes);
entries = result['entries'] as List;
} on MissingPluginException {
return compute(_parseNetworkListingInBackground, {
'protocol': connection.protocol.name,
'address': connection.address,
'path': path,
'bytes': bytes,
});
} on PlatformException catch (error) {
if (error.message?.contains('XML nesting exceeds') != true) rethrow;
return compute(_parseNetworkListingInBackground, {
'protocol': connection.protocol.name,
'address': connection.address,
'path': path,
'bytes': bytes,
});
}
return compute(_hydrateNetworkEntriesInBackground, entries);
}
Future<void> _serve(HttpRequest request) async {
HttpClient? client;
NetworkSmbClient? smb;
+64 -1
View File
@@ -121,11 +121,74 @@ extension NetworkStorageUpload on NetworkStorageService {
File file,
int length,
void Function() checkpoint,
) async {
final url = await resolve(source);
checkpoint();
final requestId = 'upload_hash_${NetworkStorageService.newId()}';
Object? cancellationError;
StackTrace? cancellationStack;
final guard = Timer.periodic(const Duration(milliseconds: 50), (timer) {
try {
checkpoint();
} catch (error, stack) {
cancellationError = error;
cancellationStack = stack;
timer.cancel();
unawaited(_cancelUploadHash(requestId));
}
});
void checkCancelled() {
final error = cancellationError;
if (error != null) {
Error.throwWithStackTrace(error, cancellationStack!);
}
}
try {
final result = await PlatformBridge.runNativeDataJob({
'operation': 'matches_network_upload',
'url': url,
'path': file.path,
'length': length,
'idle_timeout_ms': NetworkStorageService.timeout.inMilliseconds,
}, requestId: requestId);
checkCancelled();
checkpoint();
final matches = result['matches'];
if (matches is! bool) {
throw const FormatException('Invalid native upload verification');
}
return matches;
} on MissingPluginException {
guard.cancel();
checkCancelled();
return _matchesUploadInDart(url, file, length, checkpoint);
} catch (_) {
checkCancelled();
rethrow;
} finally {
guard.cancel();
}
}
Future<void> _cancelUploadHash(String requestId) async {
try {
await PlatformBridge.cancelNativeDataJob(requestId);
} catch (_) {
// The worker is still awaited before the caller can release its file.
}
}
Future<bool> _matchesUploadInDart(
String url,
File file,
int length,
void Function() checkpoint,
) async {
final client = _client();
try {
final request = await client
.getUrl(Uri.parse(await resolve(source)))
.getUrl(Uri.parse(url))
.timeout(NetworkStorageService.timeout);
final response = await request.close().timeout(
NetworkStorageService.timeout,