Files
SpotiFLAC-Mobile/lib/services/network_storage_upload.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

359 lines
11 KiB
Dart

part of 'network_storage_service.dart';
/// Writes share the browser's credentials, certificate pin and timeouts.
extension NetworkStorageUpload on NetworkStorageService {
Future<({NetworkConnection connection, String path})> _uploadLocation(
String source,
) async {
final uri = Uri.parse(source);
if (uri.scheme != 'network' ||
uri.userInfo.isNotEmpty ||
uri.hasQuery ||
uri.hasFragment) {
throw const FormatException('Invalid network destination');
}
final c = await connection(uri.host);
c.validate();
if (c.protocol == NetworkProtocol.http) {
throw const FormatException('Downloads require SMB or WebDAV');
}
return (connection: c, path: networkPath(uri.pathSegments.join('/')));
}
Future<void> _uploadCommand(
NetworkConnection c,
String method,
String path, {
Map<String, String> headers = const {},
File? file,
Set<int> accepted = const {200, 201, 204},
void Function()? checkpoint,
void Function(int)? progress,
}) async {
final client = _client();
try {
final response = await _request(
client,
c,
method,
networkTarget(c, path),
headers: headers,
upload: file,
checkpoint: checkpoint,
progress: progress,
);
if (!accepted.contains(response.statusCode)) {
throw HttpException(
'Network upload: $method returned ${response.statusCode}',
);
}
await response.drain<void>().timeout(NetworkStorageService.timeout);
} finally {
client.close(force: true);
}
}
Future<void> _makeUploadFolder(NetworkConnection c, String path) async {
if (c.protocol == NetworkProtocol.webdav) {
await _uploadCommand(c, 'MKCOL', path, accepted: {201, 405});
} else {
final smb = await _smb(c, networkTarget(c, path));
try {
if (!await smb.exists(smb.path)) {
try {
await smb.mkdir(smb.path);
} catch (_) {
// Concurrent album tracks can create the same parent folder.
if (!await smb.exists(smb.path)) rethrow;
}
}
} finally {
await smb.close();
}
}
// An existing file or a forbidden collection must not pass as a folder.
await list(c, path);
}
/// Choose a non-colliding name before transfer and persist it in the journal.
Future<String> prepareUpload(
String folder,
String relativeDir,
String filename,
) async {
final location = await _uploadLocation(folder);
final c = location.connection;
var parent = location.path;
if (parent.isNotEmpty && !parent.endsWith('/')) {
throw const FormatException('Expected a destination folder');
}
final name = networkPath(filename);
if (name.isEmpty || name.contains('/')) {
throw const FormatException('Invalid filename');
}
await list(c, parent);
for (final segment in networkPath(
relativeDir,
).split('/').where((s) => s.isNotEmpty)) {
parent += '$segment/';
await _makeUploadFolder(c, parent);
}
final entries = await list(c, parent);
final names = entries.map((e) => e.name.toLowerCase()).toSet();
final dot = name.lastIndexOf('.');
final stem = dot > 0 ? name.substring(0, dot) : name;
final suffix = dot > 0 ? name.substring(dot) : '';
var candidate = name;
bool occupied(String value) =>
names.contains(value.toLowerCase()) ||
names.contains(
'${value.substring(0, value.length - suffix.length)}.lrc'
.toLowerCase(),
);
for (var i = 2; occupied(candidate); i++) {
candidate = '$stem ($i)$suffix';
}
return NetworkStorageService.source(c.id, '$parent$candidate');
}
Future<bool> _matchesUpload(
String source,
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(url))
.timeout(NetworkStorageService.timeout);
final response = await request.close().timeout(
NetworkStorageService.timeout,
);
if (response.statusCode != 200 ||
(response.contentLength >= 0 && response.contentLength != length)) {
return false;
}
var received = 0;
final remote = await sha256
.bind(
response.timeout(NetworkStorageService.timeout).map((chunk) {
checkpoint();
received += chunk.length;
if (received > length) {
throw StateError('Network destination changed');
}
return chunk;
}),
)
.first;
final local = await sha256.bind(file.openRead()).first;
return received == length && remote == local;
} finally {
client.close(force: true);
}
}
/// Publish using a hidden temporary name and a non-overwriting rename.
/// A lost rename response is reconciled by content, never length alone.
Future<void> publishUpload(
String source,
File file,
String transferId, {
required void Function() checkpoint,
required void Function(int, int) progress,
}) async {
final location = await _uploadLocation(source);
final c = location.connection;
final path = location.path;
final slash = path.lastIndexOf('/');
final parent = slash < 0 ? '' : path.substring(0, slash + 1);
final name = path.substring(slash + 1);
final length = await file.length();
checkpoint();
final entries = await list(c, parent);
if (entries.any((e) => e.name.toLowerCase() == name.toLowerCase())) {
if (await _matchesUpload(source, file, length, checkpoint)) return;
throw StateError('Network destination already contains a different file');
}
final token = sha256.convert(utf8.encode(transferId)).toString();
final temporary = '$parent.spotiflac-$token.part';
if (c.protocol == NetworkProtocol.webdav) {
try {
await _uploadCommand(
c,
'PUT',
temporary,
file: file,
checkpoint: checkpoint,
progress: (sent) => progress(sent, length),
);
checkpoint();
final uploaded = (await list(c, parent))
.where((e) => e.name == temporary.substring(parent.length))
.firstOrNull;
if (uploaded?.size != length) {
throw StateError('Network upload size mismatch');
}
await _uploadCommand(
c,
'MOVE',
temporary,
headers: {
'Destination': networkTarget(c, path).toString(),
'Overwrite': 'F',
},
);
} catch (_) {
try {
await _uploadCommand(
c,
'DELETE',
temporary,
accepted: {200, 204, 404},
);
} catch (_) {}
rethrow;
}
} else {
final smb = await _smb(c, networkTarget(c, temporary));
try {
if (await smb.exists(smb.path)) await smb.delete(smb.path);
await smb.upload(
smb.path,
file,
checkpoint: checkpoint,
progress: (sent) => progress(sent, length),
);
checkpoint();
if ((await smb.stat()).size != length) {
throw StateError('Network upload size mismatch');
}
final destination = networkTarget(
c,
path,
).pathSegments.where((s) => s.isNotEmpty).skip(1).join('/');
// libsmb2 uses replace_if_exist=0 for rename.
await smb.rename(smb.path, destination);
} catch (_) {
try {
await smb.delete(smb.path);
} catch (_) {}
rethrow;
} finally {
await smb.close();
}
}
progress(length, length);
}
Future<void> _checkUploadAccess(String folder) async {
final location = await _uploadLocation(folder);
if (location.path.isNotEmpty && !location.path.endsWith('/')) {
throw const FormatException('Expected a folder');
}
final temporary = await Directory.systemTemp.createTemp(
'network-write-probe-',
);
final path =
'${location.path}.spotiflac-probe-${NetworkStorageService.newId()}';
try {
final file = await File('${temporary.path}/probe').writeAsBytes([0]);
if (location.connection.protocol == NetworkProtocol.webdav) {
try {
await _uploadCommand(
location.connection,
'PUT',
path,
file: file,
headers: {'If-None-Match': '*'},
);
} finally {
await _uploadCommand(
location.connection,
'DELETE',
path,
accepted: {200, 204, 404},
);
}
} else {
final smb = await _smb(
location.connection,
networkTarget(location.connection, path),
);
try {
await smb.upload(smb.path, file, checkpoint: () {}, progress: (_) {});
await smb.delete(smb.path);
} finally {
await smb.close();
}
}
} finally {
await temporary.delete(recursive: true);
}
}
}