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.
238 lines
8.8 KiB
Dart
238 lines
8.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:isolate';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:spotiflac_android/services/network_metadata_service.dart';
|
|
import 'package:spotiflac_android/services/network_storage_service.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
|
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.
|
|
/// Failed listings never count as empty directories. Partial scans retain old
|
|
/// rows through the same ingestion policy used for local/SAF scans.
|
|
class NetworkLibraryScanner {
|
|
NetworkLibraryScanner({
|
|
NetworkStorageService? storage,
|
|
Future<Map<String, dynamic>> Function(String)? readMetadata,
|
|
Future<Directory> Function()? supportDirectory,
|
|
Future<Directory> Function()? temporaryDirectory,
|
|
}) : _storage = storage ?? NetworkStorageService.instance,
|
|
_readMetadata =
|
|
readMetadata ??
|
|
((source) => NetworkMetadataService.instance.read(
|
|
source,
|
|
forceRefresh: true,
|
|
)),
|
|
_supportDirectory = supportDirectory ?? getApplicationSupportDirectory,
|
|
_temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
|
|
|
|
final NetworkStorageService _storage;
|
|
final Future<Map<String, dynamic>> Function(String) _readMetadata;
|
|
final Future<Directory> Function() _supportDirectory, _temporaryDirectory;
|
|
|
|
Future<LibraryScanNDJSONFile> scan(
|
|
String source, {
|
|
required Future<void> Function() checkpoint,
|
|
required void Function(int processed, int errors, String name) onProgress,
|
|
}) async {
|
|
final uri = Uri.parse(source);
|
|
if (uri.scheme != 'network' ||
|
|
uri.hasQuery ||
|
|
uri.hasFragment ||
|
|
uri.userInfo.isNotEmpty) {
|
|
throw const FormatException('Invalid network library source');
|
|
}
|
|
final connection = await _storage.connection(uri.host);
|
|
if (connection.protocol == NetworkProtocol.http) {
|
|
throw const FormatException('Library folders require SMB or WebDAV');
|
|
}
|
|
final root = networkPath(uri.pathSegments.join('/'));
|
|
if (root.isNotEmpty && !root.endsWith('/')) {
|
|
throw const FormatException('Expected a network folder');
|
|
}
|
|
await checkpoint();
|
|
// Fail before creating scan output if the root cannot be listed.
|
|
final first = await _storage.list(connection, root);
|
|
final temp = await Directory(
|
|
'${(await _temporaryDirectory()).path}/network_scans',
|
|
).create(recursive: true);
|
|
final covers = await Directory(
|
|
'${(await _supportDirectory()).path}/library_covers',
|
|
).create(recursive: true);
|
|
final file = File('${temp.path}/${NetworkStorageService.newId()}.ndjson');
|
|
final output = await file.open(mode: FileMode.write);
|
|
var written = 0;
|
|
var processed = 0;
|
|
var errors = 0;
|
|
final pending = <String>[root];
|
|
final visited = <String>{};
|
|
final seenFiles = <String>{};
|
|
try {
|
|
while (pending.isNotEmpty) {
|
|
await checkpoint();
|
|
final folder = pending.removeLast();
|
|
if (!visited.add(folder)) continue;
|
|
if (visited.length > 100000) {
|
|
throw StateError('Too many network folders');
|
|
}
|
|
final List<NetworkEntry> entries;
|
|
try {
|
|
entries = folder == root
|
|
? first
|
|
: await _storage.list(connection, folder);
|
|
} catch (error) {
|
|
errors++;
|
|
_log.e('Network folder listing failed [$folder]', error);
|
|
onProgress(processed, errors, folder);
|
|
continue;
|
|
}
|
|
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');
|
|
}
|
|
pending.add(path);
|
|
continue;
|
|
}
|
|
if (!entry.audio || !seenFiles.add(path)) continue;
|
|
final trackSource = NetworkStorageService.source(connection.id, path);
|
|
try {
|
|
final tags = await _readMetadata(trackSource);
|
|
if (tags['error'] != null || tags['metadataFromFilename'] == true) {
|
|
throw StateError(
|
|
tags['error']?.toString() ?? 'Network tags unavailable',
|
|
);
|
|
}
|
|
final id = 'network_${sha256.convert(utf8.encode(trackSource))}';
|
|
String? coverPath;
|
|
final cover = tags['cover_path']?.toString();
|
|
if (cover != null &&
|
|
cover.isNotEmpty &&
|
|
await File(cover).exists()) {
|
|
// Identical embedded artwork shares an immutable file, including
|
|
// across albums/sources. Cleanup retains all DB cover references.
|
|
try {
|
|
try {
|
|
if (!PlatformBridge.supportsCoreBackend) {
|
|
final directory = covers.path;
|
|
coverPath = await Isolate.run(
|
|
() => _promoteCover(cover, directory),
|
|
);
|
|
} else {
|
|
final promoted = await PlatformBridge.runNativeDataJob({
|
|
'operation': 'promote_network_cover',
|
|
'path': cover,
|
|
'directory': covers.path,
|
|
});
|
|
coverPath = promoted['cover_path'] as String;
|
|
}
|
|
} on MissingPluginException {
|
|
final directory = covers.path;
|
|
coverPath = await Isolate.run(
|
|
() => _promoteCover(cover, directory),
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
await checkpoint();
|
|
final row = networkLibraryRow(trackSource, entry, tags)
|
|
..['id'] = id
|
|
..['coverPath'] = coverPath;
|
|
await output.writeString('${jsonEncode(row)}\n');
|
|
written++;
|
|
} catch (error) {
|
|
// Cancellation must abort the whole staging operation.
|
|
await checkpoint();
|
|
errors++;
|
|
_log.e('Network track scan failed [$trackSource]', error);
|
|
}
|
|
processed++;
|
|
onProgress(processed, errors, entry.name);
|
|
}
|
|
}
|
|
await checkpoint();
|
|
await output.close();
|
|
return LibraryScanNDJSONFile(
|
|
file: file,
|
|
expectedCount: written,
|
|
errorCount: errors,
|
|
);
|
|
} catch (_) {
|
|
await output.close();
|
|
if (await file.exists()) await file.delete();
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<String?> _promoteCover(String cover, String directory) async {
|
|
final digest = await sha256.bind(File(cover).openRead()).first;
|
|
final target = File('$directory/network_cover_$digest.image');
|
|
final staged = File('${target.path}.${NetworkStorageService.newId()}.tmp');
|
|
try {
|
|
if (!await target.exists() || await target.length() == 0) {
|
|
await File(cover).copy(staged.path);
|
|
await staged.rename(target.path);
|
|
}
|
|
return target.path;
|
|
} finally {
|
|
if (await staged.exists()) await staged.delete();
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic> networkLibraryRow(
|
|
String source,
|
|
NetworkEntry entry,
|
|
Map<String, dynamic> tags,
|
|
) {
|
|
String text(String key, String fallback) {
|
|
final value = tags[key]?.toString().trim() ?? '';
|
|
return value.isEmpty ? fallback : value;
|
|
}
|
|
|
|
return {
|
|
'filePath': source,
|
|
'scannedAt': DateTime.now().toIso8601String(),
|
|
'trackName': text(
|
|
'title',
|
|
entry.name.replaceFirst(RegExp(r'\.[^.]+$'), ''),
|
|
),
|
|
'artistName': text('artist', 'Unknown Artist'),
|
|
'albumName': text('album', 'Unknown Album'),
|
|
'format': text('audio_codec', text('format', entry.extension)),
|
|
'hasLyrics': hasUsableLyricsContent(tags['lyrics']?.toString() ?? ''),
|
|
'explicit': tags['explicit'] == true,
|
|
for (final field in const {
|
|
'album_artist': 'albumArtist',
|
|
'isrc': 'isrc',
|
|
'genre': 'genre',
|
|
'date': 'releaseDate',
|
|
'composer': 'composer',
|
|
'label': 'label',
|
|
'copyright': 'copyright',
|
|
'album_type': 'albumType',
|
|
'track_number': 'trackNumber',
|
|
'total_tracks': 'totalTracks',
|
|
'disc_number': 'discNumber',
|
|
'total_discs': 'totalDiscs',
|
|
'duration': 'duration',
|
|
'bit_depth': 'bitDepth',
|
|
'sample_rate': 'sampleRate',
|
|
'bitrate': 'bitrate',
|
|
'replaygain_track_gain': 'replaygain_track_gain',
|
|
'replaygain_album_gain': 'replaygain_album_gain',
|
|
}.entries)
|
|
if (tags[field.key] != null) field.value: tags[field.key],
|
|
};
|
|
}
|