mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
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.
207 lines
7.2 KiB
Dart
207 lines
7.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:spotiflac_android/services/network_storage_service.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
|
|
typedef NetworkTagReader =
|
|
Future<Map<String, dynamic>> Function(String url, String name);
|
|
|
|
/// One bounded native tag read per source, shared by playback and Lyrics.
|
|
/// Only the selected track is inspected; browsing does not scan the NAS.
|
|
class NetworkMetadataService {
|
|
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,
|
|
_lanes = List.generate(
|
|
RangeError.checkValueInInterval(maxConcurrentReads, 1, 4),
|
|
(_) => (tail: Future<void>.value(), pending: 0),
|
|
);
|
|
|
|
static final instance = NetworkMetadataService();
|
|
final NetworkStorageService _storage;
|
|
final NetworkTagReader _reader;
|
|
final bool _useNativeReader;
|
|
final Future<Directory> Function() _directory;
|
|
final _cache = <String, ({DateTime time, Map<String, dynamic> value})>{};
|
|
final _pending = <String, Future<Map<String, dynamic>>>{};
|
|
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 &&
|
|
DateTime.now().difference(cached.time) < const Duration(minutes: 5)) {
|
|
return Future.value(cached.value);
|
|
}
|
|
final pending = _pending[source];
|
|
if (pending != null) return pending;
|
|
if (_pending.length >= 4) {
|
|
return Future.error(StateError('Network metadata reader is busy'));
|
|
}
|
|
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;
|
|
}
|
|
|
|
/// 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');
|
|
}
|
|
final connection = await _storage.connection(uri.host);
|
|
final path = uri.pathSegments.join('/');
|
|
var name =
|
|
(path.isEmpty
|
|
? connection.root.pathSegments.lastOrNull
|
|
: uri.pathSegments.lastOrNull) ??
|
|
'';
|
|
if (name.toLowerCase().endsWith('.alac')) {
|
|
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': '${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) {
|
|
try {
|
|
directory = '${(await _directory()).path}/network_metadata';
|
|
} catch (_) {}
|
|
}
|
|
loaded = await compute(_cacheNetworkMetadata, {
|
|
'source': source,
|
|
'directory': directory,
|
|
'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) {
|
|
throw StateError('Network metadata could not be read');
|
|
}
|
|
final value = Map<String, dynamic>.unmodifiable(metadata);
|
|
if (result['cacheable'] != true) return value;
|
|
_cache.remove(source);
|
|
while (_cache.length >= 32) {
|
|
_cache.remove(_cache.keys.first);
|
|
}
|
|
_cache[source] = (time: DateTime.now(), value: value);
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/// Compatibility path keeps cover decoding/hash/directory sorting off the UI
|
|
/// isolate too. Production receives only native file paths and small tags.
|
|
Future<Map<String, dynamic>> _cacheNetworkMetadata(
|
|
Map<String, dynamic> job,
|
|
) async {
|
|
final metadata = Map<String, dynamic>.from(job['metadata'] as Map);
|
|
final cover = metadata.remove('cover_base64');
|
|
if (cover is String &&
|
|
cover.isNotEmpty &&
|
|
(job['directory'] as String).isNotEmpty &&
|
|
cover.length <= 6 * 1024 * 1024) {
|
|
// A cover/cache failure must not discard successfully read tags or lyrics.
|
|
try {
|
|
final directory = Directory(job['directory'] as String);
|
|
await directory.create(recursive: true);
|
|
final file = File(
|
|
'${directory.path}/${sha256.convert(utf8.encode(job['source'] as String))}.image',
|
|
);
|
|
await file.writeAsBytes(base64Decode(cover), flush: true);
|
|
metadata['cover_path'] = file.path;
|
|
final files = await directory
|
|
.list()
|
|
.where((entry) => entry is File)
|
|
.cast<File>()
|
|
.toList();
|
|
final dated = <({File file, DateTime time})>[];
|
|
for (final entry in files) {
|
|
dated.add((file: entry, time: (await entry.stat()).modified));
|
|
}
|
|
dated.sort((a, b) => b.time.compareTo(a.time));
|
|
for (final old in dated.skip(32)) {
|
|
await old.file.delete();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
return {
|
|
'metadata': metadata,
|
|
'cacheable': utf8.encode(jsonEncode(metadata)).length <= 128 * 1024,
|
|
};
|
|
}
|