mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
perf(library): prefetch network metadata with bounded concurrency
Read up to three WebDAV tracks or two SMB tracks through a scan-owned metadata service while retaining listing order, bounded output, artwork and progress. Keep pause and cancellation independent of playback and add host regressions plus a native Android latency benchmark.
This commit is contained in:
1 parent
0d70e413ed
commit
5eafd3acc4
6 files changed
+637
-28
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -54,6 +55,7 @@ void main() {
|
||||
supportDirectory: () async => directory,
|
||||
temporaryDirectory: () async => directory,
|
||||
openOutput: openOutput,
|
||||
metadataConcurrency: 1,
|
||||
);
|
||||
|
||||
_Storage tracks(int count) => _Storage({
|
||||
@@ -70,6 +72,135 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'bounded prefetch retains listing order after out-of-order failures',
|
||||
() async {
|
||||
final gates = List.generate(6, (_) => Completer<Map<String, dynamic>>());
|
||||
final started = Completer<void>();
|
||||
final progress = <String>[];
|
||||
var reads = 0;
|
||||
var active = 0;
|
||||
var maximum = 0;
|
||||
final scan =
|
||||
NetworkLibraryScanner(
|
||||
storage: tracks(6),
|
||||
metadataConcurrency: 3,
|
||||
readMetadata: (source) async {
|
||||
final index = int.parse(
|
||||
Uri.parse(source).pathSegments.last.split('.').first,
|
||||
);
|
||||
reads++;
|
||||
active++;
|
||||
if (active > maximum) maximum = active;
|
||||
if (reads == 3) started.complete();
|
||||
try {
|
||||
return await gates[index].future;
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
supportDirectory: () async => directory,
|
||||
temporaryDirectory: () async => directory,
|
||||
).scan(
|
||||
NetworkStorageService.source('nas', ''),
|
||||
checkpoint: () async {},
|
||||
onProgress: (_, _, name) => progress.add(name),
|
||||
);
|
||||
await started.future;
|
||||
gates[2].complete({'title': '2'});
|
||||
gates[1].completeError(StateError('unreadable tags'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(progress, isEmpty);
|
||||
expect(reads, 3);
|
||||
for (final index in [0, 3, 4, 5]) {
|
||||
gates[index].complete({'title': '$index'});
|
||||
}
|
||||
final result = await scan;
|
||||
expect((await result.rows().toList()).map((row) => row['trackName']), [
|
||||
'0',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
]);
|
||||
expect(progress, List.generate(6, (index) => '$index.flac'));
|
||||
expect(result.errorCount, 1);
|
||||
expect(maximum, 3);
|
||||
expect(active, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'pause stops prefetch dispatch and resumes without skipping tracks',
|
||||
() async {
|
||||
final paused = Completer<void>();
|
||||
final resume = Completer<void>();
|
||||
var shouldPause = false;
|
||||
var reads = 0;
|
||||
final scan =
|
||||
NetworkLibraryScanner(
|
||||
storage: tracks(8),
|
||||
metadataConcurrency: 3,
|
||||
readMetadata: (_) async => {'title': '${++reads}'},
|
||||
supportDirectory: () async => directory,
|
||||
temporaryDirectory: () async => directory,
|
||||
).scan(
|
||||
NetworkStorageService.source('nas', ''),
|
||||
checkpoint: () async {
|
||||
if (shouldPause) {
|
||||
if (!paused.isCompleted) paused.complete();
|
||||
await resume.future;
|
||||
}
|
||||
},
|
||||
onProgress: (processed, _, _) {
|
||||
if (processed == 1) shouldPause = true;
|
||||
},
|
||||
);
|
||||
await paused.future;
|
||||
expect(reads, 3);
|
||||
shouldPause = false;
|
||||
resume.complete();
|
||||
final result = await scan;
|
||||
expect(result.expectedCount, 8);
|
||||
expect(reads, 8);
|
||||
expect(result.errorCount, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'prefetch cancellation drains pending errors and deletes staging',
|
||||
() async {
|
||||
final later = Completer<Map<String, dynamic>>();
|
||||
var cancelled = false;
|
||||
var reads = 0;
|
||||
final scan =
|
||||
NetworkLibraryScanner(
|
||||
storage: tracks(10),
|
||||
metadataConcurrency: 3,
|
||||
readMetadata: (_) {
|
||||
reads++;
|
||||
return reads == 1
|
||||
? Future.value({'title': 'first'})
|
||||
: later.future;
|
||||
},
|
||||
supportDirectory: () async => directory,
|
||||
temporaryDirectory: () async => directory,
|
||||
).scan(
|
||||
NetworkStorageService.source('nas', ''),
|
||||
checkpoint: () async {
|
||||
if (cancelled) throw StateError('cancelled');
|
||||
},
|
||||
onProgress: (_, _, _) {
|
||||
cancelled = true;
|
||||
later.completeError(StateError('stopped read'));
|
||||
},
|
||||
);
|
||||
await expectLater(scan, throwsStateError);
|
||||
expect(reads, 3);
|
||||
await expectNoOutput();
|
||||
},
|
||||
);
|
||||
|
||||
test('WebDAV traversal indexes 1806 tracks across encoded album folders', () async {
|
||||
final overrides = HttpOverrides.current;
|
||||
HttpOverrides.global = null;
|
||||
|
||||
@@ -76,6 +76,73 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'scan cancellation stops only its native reads and queued work',
|
||||
() async {
|
||||
final jobs = <String, Completer<String>>{};
|
||||
final started = Completer<void>();
|
||||
final cancelled = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.zarz.spotiflac/backend'),
|
||||
(call) async {
|
||||
final args = call.arguments as Map;
|
||||
final id = args['request_id'] as String;
|
||||
if (call.method == 'cancelNativeDataJob') {
|
||||
cancelled.add(id);
|
||||
jobs[id]!.completeError(PlatformException(code: 'CANCELLED'));
|
||||
return null;
|
||||
}
|
||||
final gate = Completer<String>();
|
||||
jobs[id] = gate;
|
||||
if (jobs.length == 3) started.complete();
|
||||
return gate.future;
|
||||
},
|
||||
);
|
||||
final scan = NetworkMetadataService(
|
||||
storage: storage,
|
||||
cacheDirectory: () async => directory,
|
||||
maxConcurrentReads: 2,
|
||||
);
|
||||
final playback = NetworkMetadataService(
|
||||
storage: storage,
|
||||
cacheDirectory: () async => directory,
|
||||
);
|
||||
final futures = [
|
||||
for (var i = 0; i < 4; i++)
|
||||
scan.read(NetworkStorageService.source('nas', '$i.flac')),
|
||||
];
|
||||
final settled = Future.wait(
|
||||
futures.map(
|
||||
(future) => future.then<Object>(
|
||||
(value) => value,
|
||||
onError: (Object error) => error,
|
||||
),
|
||||
),
|
||||
);
|
||||
final playing = playback.read(
|
||||
NetworkStorageService.source('nas', 'playing.flac'),
|
||||
);
|
||||
await started.future;
|
||||
await scan.cancelPending();
|
||||
expect(cancelled, hasLength(2));
|
||||
expect(await settled, everyElement(isNot(isA<Map<String, dynamic>>())));
|
||||
expect(jobs, hasLength(3));
|
||||
final survivor = jobs.keys.singleWhere((id) => !cancelled.contains(id));
|
||||
jobs[survivor]!.complete(
|
||||
jsonEncode({
|
||||
'metadata': {'title': 'playing'},
|
||||
'cacheable': true,
|
||||
}),
|
||||
);
|
||||
expect((await playing)['title'], 'playing');
|
||||
await expectLater(
|
||||
scan.read(NetworkStorageService.source('nas', 'new.flac')),
|
||||
throwsStateError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'playback and Lyrics share one read and embedded cover is cached as a file',
|
||||
() async {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/network_library_scanner.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
|
||||
class _Storage extends NetworkStorageService {
|
||||
_Storage(this.protocol);
|
||||
final NetworkProtocol protocol;
|
||||
@override
|
||||
Future<NetworkConnection> connection(String id) async => NetworkConnection(
|
||||
id: id,
|
||||
name: 'NAS fixture',
|
||||
protocol: protocol,
|
||||
address: protocol == NetworkProtocol.smb
|
||||
? 'smb://nas.test/music/'
|
||||
: 'https://nas.test/music/',
|
||||
);
|
||||
@override
|
||||
Future<List<NetworkEntry>> list(
|
||||
NetworkConnection c, [
|
||||
String path = '',
|
||||
]) async =>
|
||||
List.generate(48, (index) => NetworkEntry('$index.flac', '$index.flac'));
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
for (final protocol in [NetworkProtocol.webdav, NetworkProtocol.smb]) {
|
||||
test('scan latency and results for ${protocol.name}', () async {
|
||||
final directory = await Directory.systemTemp.createTemp('scan-latency-');
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final storage = _Storage(protocol);
|
||||
addTearDown(storage.dispose);
|
||||
const channel = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
var active = 0;
|
||||
var maximum = 0;
|
||||
messenger.setMockMethodCallHandler(channel, (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');
|
||||
active++;
|
||||
if (active > maximum) maximum = active;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 15));
|
||||
active--;
|
||||
return jsonEncode({
|
||||
'metadata': {
|
||||
'title': request['display_name'],
|
||||
'artist': 'Artist',
|
||||
'album': 'Album',
|
||||
},
|
||||
'cacheable': true,
|
||||
});
|
||||
});
|
||||
final watch = Stopwatch()..start();
|
||||
final scan =
|
||||
await NetworkLibraryScanner(
|
||||
storage: storage,
|
||||
supportDirectory: () async => directory,
|
||||
temporaryDirectory: () async => directory,
|
||||
).scan(
|
||||
NetworkStorageService.source('nas', ''),
|
||||
checkpoint: () async {},
|
||||
onProgress: (_, _, _) {},
|
||||
);
|
||||
watch.stop();
|
||||
final rows = await scan.rows().toList();
|
||||
expect(scan.errorCount, 0);
|
||||
expect(rows, hasLength(48));
|
||||
expect(
|
||||
rows.map((row) => row['trackName']),
|
||||
List.generate(48, (i) => '$i.flac'),
|
||||
);
|
||||
expect(rows.map((row) => row['id']).toSet(), hasLength(48));
|
||||
expect(active, 0);
|
||||
// This is a controlled channel-latency measurement, not a NAS benchmark.
|
||||
stdout.writeln(
|
||||
jsonEncode({
|
||||
'protocol': protocol.name,
|
||||
'milliseconds': watch.elapsedMilliseconds,
|
||||
'maximum_active_reads': maximum,
|
||||
'rows': rows.length,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user