mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +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
+87
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"listings": [
|
||||
{
|
||||
"protocol": "http",
|
||||
"address": "https://nas.test/music/",
|
||||
"base_url": "https://nas.test/music/",
|
||||
"path": "",
|
||||
"body": "<a href='../'>parent</a><a href='Folder/'>folder</a><a href='A%20%2B%23%25.flac'>audio</a><a href='音.flac'>music</a><a href='nested/song.flac'>nested</a><a href='https://other.test/music/b.flac'>other</a><a href='bad.flac?q=1'>query</a><a href='A%20%2B%23%25.flac'>duplicate</a>",
|
||||
"entries": [
|
||||
{"path": "Folder/", "name": "Folder", "directory": true, "size": null},
|
||||
{"path": "A +#%.flac", "name": "A +#%.flac", "directory": false, "size": null},
|
||||
{"path": "音.flac", "name": "音.flac", "directory": false, "size": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol": "http",
|
||||
"address": "https://nas.test/music/",
|
||||
"base_url": "https://nas.test/music/",
|
||||
"path": "",
|
||||
"body": "<a href='ib.flac'>i</a><a href='İA.flac'>dotted capital i</a><a href='%EE%80%80.flac'>private use</a><a href='%F0%90%80%80.flac'>supplementary plane</a>",
|
||||
"entries": [
|
||||
{"path": "İA.flac", "name": "İA.flac", "directory": false, "size": null},
|
||||
{"path": "ib.flac", "name": "ib.flac", "directory": false, "size": null},
|
||||
{"path": "𐀀.flac", "name": "𐀀.flac", "directory": false, "size": null},
|
||||
{"path": ".flac", "name": ".flac", "directory": false, "size": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol": "http",
|
||||
"address": "https://nas.test/music/",
|
||||
"base_url": "https://nas.test/music/",
|
||||
"path": "",
|
||||
"body": "<a href='Folder\\'>directory</a><a href=' song.flac '>edge spaces</a><a href='bad%zz.flac'>literal percent</a><a href='nested/%2E%2E/child.flac'>normalized child</a><a href='%2E%2E/outside.flac'>outside</a>",
|
||||
"entries": [
|
||||
{"path": "Folder/", "name": "Folder", "directory": true, "size": null},
|
||||
{"path": " song.flac ", "name": " song.flac ", "directory": false, "size": null},
|
||||
{"path": "bad%zz.flac", "name": "bad%zz.flac", "directory": false, "size": null},
|
||||
{"path": "child.flac", "name": "child.flac", "directory": false, "size": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol": "webdav",
|
||||
"address": "https://nas.test/dav/",
|
||||
"base_url": "https://nas.test/dav/Album/",
|
||||
"path": "Album/",
|
||||
"body": "<d:multistatus xmlns:d='DAV:'><d:response><d:href>/dav/Album/A%20B.flac</d:href><d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response><d:response><d:href>/dav/Album/Missing.flac</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>",
|
||||
"entries": [
|
||||
{"path": "Album/A B.flac", "name": "A B.flac", "directory": false, "size": 123}
|
||||
]
|
||||
},
|
||||
{
|
||||
"protocol": "http",
|
||||
"address": "https://nas.test/music/",
|
||||
"base_url": "https://nas.test/music/",
|
||||
"path": "",
|
||||
"body": "<a href='a%2fb.flac'>nested</a><a href='https://user@nas.test/music/private.flac'>credentials</a><a href='ok.flac#part'>fragment</a>",
|
||||
"entries": []
|
||||
}
|
||||
],
|
||||
"playlists": [
|
||||
{
|
||||
"format": "csv", "id_seed": 42,
|
||||
"body": "title,artist\nA,B\n\nC,D",
|
||||
"tracks": [
|
||||
{"id": "csv_42_1", "name": "A", "artistName": "B", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null},
|
||||
{"id": "csv_42_3", "name": "C", "artistName": "D", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"format": "csv", "id_seed": 10,
|
||||
"body": "\r\nTrack Name,Artist Name(s),Album Name,Track URI,ISRC\r\n\"Song, \"\"One\"\"\",Artist,Album,spotify:track:abc,AAABC1200001\r\nSecond,,Album,,\r\n",
|
||||
"tracks": [
|
||||
{"id": "abc", "name": "Song, \"One\"", "artistName": "Artist", "albumName": "Album", "duration": 0, "isrc": "AAABC1200001", "coverUrl": null},
|
||||
{"id": "", "name": "Second", "artistName": "", "albumName": "Album", "duration": 0, "isrc": "", "coverUrl": null}
|
||||
]
|
||||
},
|
||||
{
|
||||
"format": "m3u", "id_seed": 100,
|
||||
"body": "#EXTM3U\r#EXTINF:180.5,Artist - Song - Mix\r#comment\r\nfolder/a.flac\nMusic/02 Demo.flac\n#EXTINF:-1,\nx/.hidden",
|
||||
"tracks": [
|
||||
{"id": "m3u_100_0", "name": "Song - Mix", "artistName": "Artist", "albumName": "Unknown Album", "duration": 181, "isrc": null, "coverUrl": null},
|
||||
{"id": "m3u_100_1", "name": "02 Demo", "artistName": "Unknown Artist", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null},
|
||||
{"id": "m3u_100_2", "name": ".hidden", "artistName": "Unknown Artist", "albumName": "Unknown Album", "duration": 0, "isrc": null, "coverUrl": null}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
final fixtures = jsonDecode(
|
||||
File('test/fixtures/native_metadata_parsers.json').readAsStringSync(),
|
||||
) as Map<String, dynamic>;
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
for (final (index, raw) in (fixtures['listings'] as List).indexed) {
|
||||
final fixture = Map<String, dynamic>.from(raw as Map);
|
||||
test('listing worker retains synchronous parser contract $index', () async {
|
||||
final connection = NetworkConnection(
|
||||
id: 'fixture',
|
||||
name: 'fixture',
|
||||
protocol: NetworkProtocol.values.byName(fixture['protocol'] as String),
|
||||
address: fixture['address'] as String,
|
||||
);
|
||||
final body = fixture['body'] as String;
|
||||
final path = fixture['path'] as String;
|
||||
final legacy = NetworkStorageService.parseListing(connection, path, body);
|
||||
final worker = await NetworkStorageService.parseListingInBackground(
|
||||
connection,
|
||||
path,
|
||||
Uint8List.fromList(utf8.encode(body)),
|
||||
);
|
||||
List<Map<String, dynamic>> rows(List<NetworkEntry> entries) => entries
|
||||
.map(
|
||||
(entry) => {
|
||||
'path': entry.path,
|
||||
'name': entry.name,
|
||||
'directory': entry.directory,
|
||||
'size': entry.size,
|
||||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
expect(rows(legacy), fixture['entries']);
|
||||
expect(rows(worker), fixture['entries']);
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'runNativeDataJob');
|
||||
final arguments = call.arguments as Map;
|
||||
final request = jsonDecode(arguments['request_json'] as String) as Map;
|
||||
expect(request, {
|
||||
'operation': 'parse_network_listing',
|
||||
'protocol': fixture['protocol'],
|
||||
'base_url': fixture['base_url'],
|
||||
'path': path,
|
||||
});
|
||||
expect(arguments['bytes'] as Uint8List, utf8.encode(body));
|
||||
return jsonEncode({'entries': fixture['entries']});
|
||||
});
|
||||
try {
|
||||
final nativeRows = await NetworkStorageService.parseListingInBackground(
|
||||
connection,
|
||||
path,
|
||||
Uint8List.fromList(utf8.encode(body)),
|
||||
);
|
||||
expect(rows(nativeRows), fixture['entries']);
|
||||
} finally {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('deep valid WebDAV uses the isolated compatibility parser', () async {
|
||||
final connection = NetworkConnection(
|
||||
id: 'deep-dav',
|
||||
name: 'deep-dav',
|
||||
protocol: NetworkProtocol.webdav,
|
||||
address: 'https://nas.test/dav/',
|
||||
);
|
||||
final body =
|
||||
'<d:multistatus xmlns:d="DAV:">${List.filled(18, '<d:wrapper>').join()}'
|
||||
'<d:response><d:href>/dav/Album/Song.flac</d:href>'
|
||||
'<d:propstat><d:prop><d:getcontentlength>123</d:getcontentlength>'
|
||||
'</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>'
|
||||
'</d:response>${List.filled(18, '</d:wrapper>').join()}</d:multistatus>';
|
||||
final bytes = Uint8List.fromList(utf8.encode(body));
|
||||
final legacy = NetworkStorageService.parseListing(
|
||||
connection,
|
||||
'Album/',
|
||||
body,
|
||||
);
|
||||
expect(legacy.single.name, 'Song.flac');
|
||||
var calls = 0;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'runNativeDataJob');
|
||||
calls++;
|
||||
throw PlatformException(
|
||||
code: 'data_job_error',
|
||||
message: 'XML nesting exceeds 16 elements',
|
||||
);
|
||||
});
|
||||
try {
|
||||
final actual = await NetworkStorageService.parseListingInBackground(
|
||||
connection,
|
||||
'Album/',
|
||||
bytes,
|
||||
);
|
||||
expect(actual.single.path, legacy.single.path);
|
||||
expect(actual.single.name, legacy.single.name);
|
||||
expect(actual.single.directory, legacy.single.directory);
|
||||
expect(actual.single.size, legacy.single.size);
|
||||
expect(calls, 1);
|
||||
messenger.setMockMethodCallHandler(channel, (_) async {
|
||||
throw PlatformException(
|
||||
code: 'data_job_error',
|
||||
message: 'Invalid listing document',
|
||||
);
|
||||
});
|
||||
await expectLater(
|
||||
NetworkStorageService.parseListingInBackground(
|
||||
connection,
|
||||
'Album/',
|
||||
bytes,
|
||||
),
|
||||
throwsA(isA<PlatformException>()),
|
||||
);
|
||||
} finally {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
import 'package:spotiflac_android/services/network_metadata_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late NetworkStorageService storage;
|
||||
late Directory directory;
|
||||
setUp(() async {
|
||||
@@ -25,10 +27,55 @@ void main() {
|
||||
directory = await Directory.systemTemp.createTemp('network-tag-test-');
|
||||
});
|
||||
tearDown(() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.zarz.spotiflac/backend'),
|
||||
null,
|
||||
);
|
||||
await storage.dispose();
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
|
||||
test(
|
||||
'native cache returns a file path and excludes oversized tags from memory',
|
||||
() async {
|
||||
var calls = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.zarz.spotiflac/backend'),
|
||||
(call) async {
|
||||
expect(call.method, 'runNativeDataJob');
|
||||
final args = call.arguments as Map;
|
||||
final request = jsonDecode(args['request_json'] as String) as Map;
|
||||
expect(request['operation'], 'network_metadata');
|
||||
expect(request['url'], startsWith('http://127.0.0.1:'));
|
||||
expect(
|
||||
request['cache_directory'],
|
||||
'${directory.path}/network_metadata',
|
||||
);
|
||||
expect(args['request_id'], isA<String>());
|
||||
calls++;
|
||||
return jsonEncode({
|
||||
'metadata': {
|
||||
'title': 'Native tags',
|
||||
'cover_path': '/native/cover.image',
|
||||
},
|
||||
'cacheable': calls != 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
final reader = NetworkMetadataService(
|
||||
storage: storage,
|
||||
cacheDirectory: () async => directory,
|
||||
);
|
||||
final source = NetworkStorageService.source('nas', 'song.flac');
|
||||
expect((await reader.read(source))['cover_path'], '/native/cover.image');
|
||||
await reader.read(source);
|
||||
await reader.read(source);
|
||||
expect(calls, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'playback and Lyrics share one read and embedded cover is cached as a file',
|
||||
() async {
|
||||
|
||||
@@ -29,6 +29,11 @@ NetworkConnection connection(
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
// Listing now crosses a method channel before the isolated host fallback.
|
||||
// Keep the in-process transport fixtures on real HTTP clients.
|
||||
HttpOverrides.global = null;
|
||||
|
||||
test('paths retain literal percent, spaces, Unicode, plus and hash', () {
|
||||
final c = connection('https://example.test/Music%20Library/');
|
||||
const path = '日本語/100% + #1.flac';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/network_download_staging.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
@@ -97,6 +99,10 @@ class _Dav {
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
// These integration tests use an authenticated in-process WebDAV server.
|
||||
HttpOverrides.global = null;
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
late _Dav dav;
|
||||
late NetworkStorageService storage;
|
||||
late NetworkDownloadStaging staging;
|
||||
@@ -126,6 +132,8 @@ void main() {
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
await storage.dispose();
|
||||
await dav.server.close(force: true);
|
||||
await temp.delete(recursive: true);
|
||||
@@ -327,6 +335,96 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'native recovery receives only the authenticated proxy and local path',
|
||||
() async {
|
||||
final file = await prepare(dir: '');
|
||||
dav.loseMoveResponse = true;
|
||||
await expectLater(publish(), throwsA(anything));
|
||||
var nativeHashes = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
final args = Map<String, dynamic>.from(call.arguments as Map);
|
||||
final request = jsonDecode(args['request_json'] as String) as Map;
|
||||
if (request['operation'] != 'matches_network_upload') {
|
||||
throw MissingPluginException();
|
||||
}
|
||||
final uri = Uri.parse(request['url'] as String);
|
||||
expect(uri.scheme, 'http');
|
||||
expect(uri.host, '127.0.0.1');
|
||||
expect(uri.hasPort, true);
|
||||
expect(uri.path, matches(r'^/[a-f0-9]{48}$'));
|
||||
expect(jsonEncode(request), isNot(contains('secret')));
|
||||
expect(request['length'], await file.length());
|
||||
expect(
|
||||
await File(request['path'] as String).readAsBytes(),
|
||||
await file.readAsBytes(),
|
||||
);
|
||||
expect(request['idle_timeout_ms'], 20000);
|
||||
nativeHashes++;
|
||||
return {'matches': true};
|
||||
});
|
||||
await publish();
|
||||
expect(nativeHashes, 1);
|
||||
expect(dav.puts, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'native recovery cancellation keeps staging until the worker completes',
|
||||
() async {
|
||||
final file = await prepare(dir: '');
|
||||
dav.loseMoveResponse = true;
|
||||
await expectLater(publish(), throwsA(anything));
|
||||
final started = Completer<void>();
|
||||
final cancelled = Completer<void>();
|
||||
final worker = Completer<Map<String, dynamic>>();
|
||||
String? requestId;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
final args = Map<String, dynamic>.from(call.arguments as Map);
|
||||
if (call.method == 'cancelNativeDataJob') {
|
||||
expect(args['request_id'], requestId);
|
||||
cancelled.complete();
|
||||
return null;
|
||||
}
|
||||
final request = jsonDecode(args['request_json'] as String) as Map;
|
||||
if (request['operation'] != 'matches_network_upload') {
|
||||
throw MissingPluginException();
|
||||
}
|
||||
requestId = args['request_id'] as String;
|
||||
started.complete();
|
||||
return worker.future;
|
||||
});
|
||||
var stop = false;
|
||||
var completed = false;
|
||||
final originalError = StateError('user cancelled verification');
|
||||
final result = publish(
|
||||
checkpoint: () {
|
||||
if (stop) throw originalError;
|
||||
},
|
||||
);
|
||||
final expectation = expectLater(result, throwsA(same(originalError)));
|
||||
unawaited(
|
||||
result.then(
|
||||
(_) => completed = true,
|
||||
onError: (Object _) {
|
||||
completed = true;
|
||||
},
|
||||
),
|
||||
);
|
||||
await started.future;
|
||||
stop = true;
|
||||
await cancelled.future;
|
||||
expect(completed, false);
|
||||
expect(await file.exists(), true);
|
||||
worker.completeError(PlatformException(code: 'cancelled'));
|
||||
await expectation;
|
||||
expect(dav.puts, 1);
|
||||
expect(await file.exists(), true);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'same-length conflicting file is never overwritten or adopted',
|
||||
() async {
|
||||
|
||||
@@ -7,6 +7,10 @@ import 'package:spotiflac_android/services/network_storage_error.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
// The native listing fallback needs a binding while TLS must use real I/O.
|
||||
HttpOverrides.global = null;
|
||||
|
||||
test(
|
||||
'self-signed WebDAV requires explicit trust for listing and streaming',
|
||||
() async {
|
||||
|
||||
Reference in new issue
Block a user