From 5eafd3acc49e52f7fa7adc71aa1e8670abdfdbf2 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:22:41 +0700 Subject: [PATCH] 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. --- integration_test/network_scan_speed_test.dart | 216 ++++++++++++++++++ lib/services/network_library_scanner.dart | 100 ++++++-- lib/services/network_metadata_service.dart | 58 ++++- test/network_library_scanner_test.dart | 131 +++++++++++ test/network_metadata_service_test.dart | 67 ++++++ test/network_scan_latency_test.dart | 93 ++++++++ 6 files changed, 637 insertions(+), 28 deletions(-) create mode 100644 integration_test/network_scan_speed_test.dart create mode 100644 test/network_scan_latency_test.dart diff --git a/integration_test/network_scan_speed_test.dart b/integration_test/network_scan_speed_test.dart new file mode 100644 index 00000000..d1c7c0e7 --- /dev/null +++ b/integration_test/network_scan_speed_test.dart @@ -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 = {}; + server.listen((request) async { + try { + await request.drain(); + ports.add(request.connectionInfo!.remotePort); + if (request.method == 'PROPFIND') { + expect(request.headers.value('depth'), '1'); + request.response.statusCode = 207; + request.response.write(''); + for (var index = 0; index < count; index++) { + final name = index.toString().padLeft(3, '0'); + request.response.write( + '/music/$name.flac' + '${files[index].length}' + 'HTTP/1.1 200 OK', + ); + } + request.response.write(''); + } 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.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 = >[]; + List>? 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 = []; + 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); + } + }, + ); +} diff --git a/lib/services/network_library_scanner.dart b/lib/services/network_library_scanner.dart index 66f0d4f5..00341663 100644 --- a/lib/services/network_library_scanner.dart +++ b/lib/services/network_library_scanner.dart @@ -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 Function()? supportDirectory, Future Function()? temporaryDirectory, Future 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> Function(String) _readMetadata; + final Future> Function(String)? _readMetadata; + final int? _metadataConcurrency; final Future Function() _supportDirectory, _temporaryDirectory; final Future Function(File) _openOutput; @@ -76,6 +77,21 @@ class NetworkLibraryScanner { final pending = [root]; final visited = {}; final seenFiles = {}; + // 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 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? 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 _promoteCoverInBackground(String cover, String directory) => diff --git a/lib/services/network_metadata_service.dart b/lib/services/network_metadata_service.dart index d8397850..cfa6f26f 100644 --- a/lib/services/network_metadata_service.dart +++ b/lib/services/network_metadata_service.dart @@ -19,13 +19,18 @@ class NetworkMetadataService { NetworkStorageService? storage, NetworkTagReader? reader, Future 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.value(), pending: 0), + ); static final instance = NetworkMetadataService(); final NetworkStorageService _storage; @@ -34,12 +39,17 @@ class NetworkMetadataService { final Future Function() _directory; final _cache = value})>{}; final _pending = >>{}; - Future _tail = Future.value(); + final List<({Future tail, int pending})> _lanes; + final _requestIds = {}; + bool _stopped = false; Future> 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((_) {}, 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((_) {}, 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> _load(String source) async { + /// Scan-owned readers can stop their work without cancelling playback reads. + Future 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> _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? 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.from(result['metadata'] as Map); if (metadata['error']?.toString().isNotEmpty == true) { diff --git a/test/network_library_scanner_test.dart b/test/network_library_scanner_test.dart index 85d08ed6..79c64801 100644 --- a/test/network_library_scanner_test.dart +++ b/test/network_library_scanner_test.dart @@ -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>()); + final started = Completer(); + final progress = []; + 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.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(); + final resume = Completer(); + 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>(); + 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; diff --git a/test/network_metadata_service_test.dart b/test/network_metadata_service_test.dart index a7144efa..df5b77a4 100644 --- a/test/network_metadata_service_test.dart +++ b/test/network_metadata_service_test.dart @@ -76,6 +76,73 @@ void main() { }, ); + test( + 'scan cancellation stops only its native reads and queued work', + () async { + final jobs = >{}; + final started = Completer(); + final cancelled = []; + 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(); + 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( + (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>()))); + 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 { diff --git a/test/network_scan_latency_test.dart b/test/network_scan_latency_test.dart new file mode 100644 index 00000000..4a75e44c --- /dev/null +++ b/test/network_scan_latency_test.dart @@ -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 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( + 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.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, + }), + ); + }); + } +}