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

@@ -0,0 +1,216 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/network_library_scanner.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
Uint8List _track(int index) {
final comments = BytesBuilder();
void little(int number) => comments.add(
(ByteData(4)..setUint32(0, number, Endian.little)).buffer.asUint8List(),
);
little(0);
final tags = [
'TITLE=Track $index',
'ARTIST=Network artist',
'ALBUM=Network album',
'LYRICS=[00:01.00]Hello\n[00:02.00]World',
'REPLAYGAIN_TRACK_GAIN=-5.0 dB',
];
little(tags.length);
for (final tag in tags) {
final bytes = utf8.encode(tag);
little(bytes.length);
comments.add(bytes);
}
final picture = BytesBuilder();
void big(int number) =>
picture.add((ByteData(4)..setUint32(0, number)).buffer.asUint8List());
final png = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a3WQAAAAASUVORK5CYII=',
);
big(3);
big(9);
picture.add(ascii.encode('image/png'));
big(0);
big(1);
big(1);
big(24);
big(0);
big(png.length);
picture.add(png);
final header = BytesBuilder()..add(ascii.encode('fLaC'));
void block(int type, Uint8List data) {
header.add([
type,
(data.length >> 16) & 255,
(data.length >> 8) & 255,
data.length & 255,
]);
header.add(data);
}
block(0, Uint8List(34));
block(4, comments.takeBytes());
block(0x86, picture.takeBytes());
header.add([0xff, 0xf8]);
final file = Uint8List(4 * 1024 * 1024);
final metadata = header.takeBytes();
file.setRange(0, metadata.length, metadata);
return file;
}
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
HttpOverrides.global = null;
testWidgets(
'native WebDAV scan overlaps latency without changing metadata or artwork',
(tester) async {
const count = 48;
final files = List.generate(count, _track);
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final temporary = await (await getTemporaryDirectory()).createTemp(
'network-speed-',
);
final storage = NetworkStorageService(
read: () async => null,
write: (_) async {},
);
final connection = NetworkConnection(
id: 'scan-speed',
name: 'Latency fixture',
protocol: NetworkProtocol.webdav,
address: 'http://127.0.0.1:${server.port}/music/',
);
await storage.save(connection);
var requests = 0;
var transferred = 0;
var active = 0;
var maximum = 0;
final ports = <int>{};
server.listen((request) async {
try {
await request.drain<void>();
ports.add(request.connectionInfo!.remotePort);
if (request.method == 'PROPFIND') {
expect(request.headers.value('depth'), '1');
request.response.statusCode = 207;
request.response.write('<d:multistatus xmlns:d="DAV:">');
for (var index = 0; index < count; index++) {
final name = index.toString().padLeft(3, '0');
request.response.write(
'<d:response><d:href>/music/$name.flac</d:href><d:propstat>'
'<d:prop><d:getcontentlength>${files[index].length}</d:getcontentlength></d:prop>'
'<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
);
}
request.response.write('</d:multistatus>');
} else {
final index = int.parse(
request.uri.pathSegments.last.split('.').first,
);
final file = files[index];
final range = networkByteRange(
request.headers.value('range'),
file.length,
);
active++;
requests++;
if (active > maximum) maximum = active;
await Future<void>.delayed(const Duration(milliseconds: 40));
request.response.statusCode = 206;
request.response.headers.set(
'Content-Range',
'bytes ${range.start}-${range.end}/${file.length}',
);
request.response.contentLength = range.end - range.start + 1;
if (request.method != 'HEAD') {
transferred += request.response.contentLength;
request.response.add(
Uint8List.sublistView(file, range.start, range.end + 1),
);
}
active--;
}
await request.response.close();
} catch (error) {
request.response.statusCode = 500;
await request.response.close();
rethrow;
}
});
try {
final samples = <Map<String, Object>>[];
List<Map<String, dynamic>>? baseline;
// Warm native bindings and parsers before comparing measured runs.
for (final concurrency in [3, 1, 3, 1, 3]) {
requests = 0;
transferred = 0;
maximum = 0;
ports.clear();
final progress = <int>[];
final watch = Stopwatch()..start();
final result =
await NetworkLibraryScanner(
storage: storage,
metadataConcurrency: concurrency,
supportDirectory: () async => temporary,
temporaryDirectory: () async => temporary,
).scan(
NetworkStorageService.source(connection.id, ''),
checkpoint: () async {},
onProgress: (processed, _, _) => progress.add(processed),
);
watch.stop();
expect(result.errorCount, 0);
final rows = await result.rows().toList();
expect(rows, hasLength(count));
expect(progress, List.generate(count, (index) => index + 1));
for (var index = 0; index < count; index++) {
final row = rows[index];
expect(row['trackName'], 'Track $index');
expect(row['artistName'], 'Network artist');
expect(row['albumName'], 'Network album');
expect(row['hasLyrics'], true);
expect(row['replaygain_track_gain'], isNotNull);
final cover = File(row['coverPath'] as String);
expect(await cover.exists(), true);
expect(await cover.length(), 68);
row.remove('scannedAt');
}
if (baseline != null) expect(rows, baseline);
baseline = rows;
expect(transferred, lessThan(count * 1024 * 1024));
expect(maximum, concurrency);
expect(active, 0);
expect(ports.length, lessThanOrEqualTo(3));
samples.add({
'concurrency': concurrency,
'milliseconds': watch.elapsedMilliseconds,
'rows': rows.length,
'errors': result.errorCount,
'range_requests': requests,
'bytes_transferred': transferred,
'maximum_active_reads': maximum,
'remote_connections': ports.length,
});
}
binding.reportData ??= {};
binding.reportData!['network_scan_speed'] = {
'completed_cases': 1,
'samples': samples,
'note':
'Native Rust metadata reader over local WebDAV; 40 ms simulated request latency. First sample is warmup. No live NAS comparison.',
};
} finally {
await storage.dispose();
await server.close(force: true);
await temporary.delete(recursive: true);
}
},
);
}
+79 -21
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
@@ -13,7 +14,7 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary');
/// A single tag read at a time; only NDJSON and artwork are stored locally.
/// Bounded tag prefetch; only NDJSON and artwork are stored locally.
/// Failed listings never count as empty directories. Partial scans retain old
/// rows through the same ingestion policy used for local/SAF scans.
class NetworkLibraryScanner {
@@ -23,19 +24,19 @@ class NetworkLibraryScanner {
Future<Directory> Function()? supportDirectory,
Future<Directory> Function()? temporaryDirectory,
Future<RandomAccessFile> Function(File)? openOutput,
int? metadataConcurrency,
}) : _storage = storage ?? NetworkStorageService.instance,
_readMetadata =
readMetadata ??
((source) => NetworkMetadataService.instance.read(
source,
forceRefresh: true,
)),
_readMetadata = readMetadata,
_metadataConcurrency = metadataConcurrency == null
? null
: RangeError.checkValueInInterval(metadataConcurrency, 1, 4),
_supportDirectory = supportDirectory ?? getApplicationSupportDirectory,
_temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory,
_openOutput = openOutput ?? ((file) => file.open(mode: FileMode.write));
final NetworkStorageService _storage;
final Future<Map<String, dynamic>> Function(String) _readMetadata;
final Future<Map<String, dynamic>> Function(String)? _readMetadata;
final int? _metadataConcurrency;
final Future<Directory> Function() _supportDirectory, _temporaryDirectory;
final Future<RandomAccessFile> Function(File) _openOutput;
@@ -76,6 +77,21 @@ class NetworkLibraryScanner {
final pending = <String>[root];
final visited = <String>{};
final seenFiles = <String>{};
// Leave room for playback and avoid saturating a small NAS/router.
final concurrency =
_metadataConcurrency ??
(connection.protocol == NetworkProtocol.smb ? 2 : 3);
final metadata = _readMetadata == null
? NetworkMetadataService(
storage: _storage,
maxConcurrentReads: concurrency,
cacheDirectory: _temporaryDirectory,
)
: null;
final read =
_readMetadata ??
((String source) => metadata!.read(source, forceRefresh: true));
final prefetched = <_PendingNetworkTrack>[];
try {
final writer = await _openOutput(file);
output = writer;
@@ -115,23 +131,51 @@ class NetworkLibraryScanner {
'${entries.where((entry) => entry.directory).length} folders, '
'${entries.where((entry) => entry.audio).length} audio files',
);
for (final entry in entries) {
await checkpoint();
final path = networkPath(entry.path);
// Reject provider parent links and paths outside this folder.
if (!path.startsWith(folder) || path == folder) continue;
if (entry.directory) {
if (!path.endsWith('/')) {
throw const FormatException('Invalid folder entry');
final iterator = entries.iterator;
Future<void> fill() async {
while (prefetched.length < concurrency && iterator.moveNext()) {
await checkpoint();
final entry = iterator.current;
final path = networkPath(entry.path);
// Reject provider parent links and paths outside this folder.
if (!path.startsWith(folder) || path == folder) continue;
if (entry.directory) {
if (!path.endsWith('/')) {
throw const FormatException('Invalid folder entry');
}
pending.add(path);
continue;
}
pending.add(path);
continue;
if (!entry.audio || !seenFiles.add(path)) continue;
final trackSource = NetworkStorageService.source(
connection.id,
path,
);
// Capture failures immediately: a later result can fail while the
// first read is still running. Consume results in listing order.
final result = Future.sync(() => read(trackSource))
.then<_NetworkTags>(
(tags) => (tags: tags, error: null, stack: null),
onError: (Object error, StackTrace stack) =>
(tags: null, error: error, stack: stack),
);
prefetched.add((entry: entry, source: trackSource, result: result));
}
if (!entry.audio || !seenFiles.add(path)) continue;
final trackSource = NetworkStorageService.source(connection.id, path);
}
await fill();
while (prefetched.isNotEmpty) {
await checkpoint();
final next = prefetched.removeAt(0);
final entry = next.entry;
final trackSource = next.source;
String? encodedRow;
try {
final tags = await _readMetadata(trackSource);
final result = await next.result;
if (result.error != null) {
Error.throwWithStackTrace(result.error!, result.stack!);
}
final tags = result.tags!;
if (tags['error'] != null || tags['metadataFromFilename'] == true) {
throw StateError(
tags['error']?.toString() ?? 'Network tags unavailable',
@@ -196,6 +240,7 @@ class NetworkLibraryScanner {
}
processed++;
onProgress(processed, errors, entry.name);
await fill();
}
}
await checkpoint();
@@ -214,6 +259,8 @@ class NetworkLibraryScanner {
errorCount: errors,
);
} catch (_) {
await metadata?.cancelPending();
await Future.wait(prefetched.map((track) => track.result));
try {
await output?.close();
} catch (error) {
@@ -229,6 +276,17 @@ class NetworkLibraryScanner {
}
}
typedef _NetworkTags = ({
Map<String, dynamic>? tags,
Object? error,
StackTrace? stack,
});
typedef _PendingNetworkTrack = ({
NetworkEntry entry,
String source,
Future<_NetworkTags> result,
});
// Keep the isolate closure outside scan's scope so it cannot capture an open
// output handle or the buffering closures, which are not isolate-sendable.
Future<String?> _promoteCoverInBackground(String cover, String directory) =>
+51 -7
View File
@@ -19,13 +19,18 @@ class NetworkMetadataService {
NetworkStorageService? storage,
NetworkTagReader? reader,
Future<Directory> Function()? cacheDirectory,
int maxConcurrentReads = 1,
}) : _storage = storage ?? NetworkStorageService.instance,
_useNativeReader = reader == null,
_reader =
reader ??
((url, name) =>
PlatformBridge.readFileMetadata(url, displayName: name)),
_directory = cacheDirectory ?? getTemporaryDirectory;
_directory = cacheDirectory ?? getTemporaryDirectory,
_lanes = List.generate(
RangeError.checkValueInInterval(maxConcurrentReads, 1, 4),
(_) => (tail: Future<void>.value(), pending: 0),
);
static final instance = NetworkMetadataService();
final NetworkStorageService _storage;
@@ -34,12 +39,17 @@ class NetworkMetadataService {
final Future<Directory> Function() _directory;
final _cache = <String, ({DateTime time, Map<String, dynamic> value})>{};
final _pending = <String, Future<Map<String, dynamic>>>{};
Future<void> _tail = Future.value();
final List<({Future<void> tail, int pending})> _lanes;
final _requestIds = <String>{};
bool _stopped = false;
Future<Map<String, dynamic>> read(
String source, {
bool forceRefresh = false,
}) {
if (_stopped) {
return Future.error(StateError('Network metadata reader stopped'));
}
if (forceRefresh) _cache.remove(source);
final cached = _cache[source];
if (cached != null &&
@@ -51,16 +61,44 @@ class NetworkMetadataService {
if (_pending.length >= 4) {
return Future.error(StateError('Network metadata reader is busy'));
}
final operation = _tail.then((_) => _load(source));
_tail = operation.then<void>((_) {}, onError: (Object _, StackTrace _) {});
var lane = 0;
for (var i = 1; i < _lanes.length; i++) {
if (_lanes[i].pending < _lanes[lane].pending) lane = i;
}
final requestId = 'network_metadata_${NetworkStorageService.newId()}';
_requestIds.add(requestId);
final operation = _lanes[lane].tail.then((_) => _load(source, requestId));
_lanes[lane] = (
tail: operation.then<void>((_) {}, onError: (Object _, StackTrace _) {}),
pending: _lanes[lane].pending + 1,
);
final result = operation.whenComplete(() {
_pending.remove(source);
_requestIds.remove(requestId);
_lanes[lane] = (
tail: _lanes[lane].tail,
pending: _lanes[lane].pending - 1,
);
});
_pending[source] = result;
return result;
}
Future<Map<String, dynamic>> _load(String source) async {
/// Scan-owned readers can stop their work without cancelling playback reads.
Future<void> cancelPending() async {
_stopped = true;
await Future.wait([
for (final id in _requestIds.toList())
PlatformBridge.cancelNativeDataJob(id).catchError((Object _) {}),
]);
}
void _checkStopped() {
if (_stopped) throw StateError('Network metadata reader stopped');
}
Future<Map<String, dynamic>> _load(String source, String requestId) async {
_checkStopped();
final uri = Uri.parse(source);
if (uri.scheme != 'network') {
throw const FormatException('Expected a network source');
@@ -76,21 +114,26 @@ class NetworkMetadataService {
name = '${name.substring(0, name.length - 5)}.m4a';
}
final url = await _storage.resolve(source);
_checkStopped();
Map<String, dynamic>? loaded;
if (_useNativeReader) {
try {
final directory = await _directory();
_checkStopped();
loaded = await PlatformBridge.runNativeDataJob({
'operation': 'network_metadata',
'url': url,
'display_name': name,
'cache_directory': '${(await _directory()).path}/network_metadata',
});
'cache_directory': '${directory.path}/network_metadata',
}, requestId: requestId);
} on MissingPluginException {
// Test/unsupported hosts retain the same reader on an isolate.
}
}
if (loaded == null) {
_checkStopped();
final tags = await _reader(url, name);
_checkStopped();
String directory = '';
if (tags['cover_base64'] is String &&
(tags['cover_base64'] as String).isNotEmpty) {
@@ -104,6 +147,7 @@ class NetworkMetadataService {
'metadata': tags,
});
}
_checkStopped();
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) {
+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,
}),
);
});
}
}