mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 14:37:33 +02:00
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:
1 parent
867371cb67
commit
927fd033cd
15 files changed
+1349
-98
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user