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:
zarzet committed 2026-10-10 23:22:41 +07:00
1 parent 0d70e413ed
commit 5eafd3acc4
6 files changed
+637 -28

No files matched your search

+131
View File
@@ -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;
+67
View File
@@ -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 {
+93
View File
@@ -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,
}),
);
});
}
}