mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
Move WebDAV/HTML parsing, content-addressed artwork caching, cover promotion, and streamed upload verification to native workers. Preserve proxy authentication, cancellation, cache limits, and deep XML fallback behavior.
163 lines
5.8 KiB
Dart
163 lines
5.8 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,
|
|
}) : _storage = storage ?? NetworkStorageService.instance,
|
|
_useNativeReader = reader == null,
|
|
_reader =
|
|
reader ??
|
|
((url, name) =>
|
|
PlatformBridge.readFileMetadata(url, displayName: name)),
|
|
_directory = cacheDirectory ?? getTemporaryDirectory;
|
|
|
|
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>>>{};
|
|
Future<void> _tail = Future.value();
|
|
|
|
Future<Map<String, dynamic>> read(
|
|
String source, {
|
|
bool forceRefresh = false,
|
|
}) {
|
|
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'));
|
|
}
|
|
final operation = _tail.then((_) => _load(source));
|
|
_tail = operation.then<void>((_) {}, onError: (Object _, StackTrace _) {});
|
|
final result = operation.whenComplete(() {
|
|
_pending.remove(source);
|
|
});
|
|
_pending[source] = result;
|
|
return result;
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _load(String source) async {
|
|
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);
|
|
Map<String, dynamic>? loaded;
|
|
if (_useNativeReader) {
|
|
try {
|
|
loaded = await PlatformBridge.runNativeDataJob({
|
|
'operation': 'network_metadata',
|
|
'url': url,
|
|
'display_name': name,
|
|
'cache_directory': '${(await _directory()).path}/network_metadata',
|
|
});
|
|
} on MissingPluginException {
|
|
// Test/unsupported hosts retain the same reader on an isolate.
|
|
}
|
|
}
|
|
if (loaded == null) {
|
|
final tags = await _reader(url, name);
|
|
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,
|
|
});
|
|
}
|
|
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,
|
|
};
|
|
}
|