mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-18 21:47:34 +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
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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