mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 05:12:29 +02:00
perf(library): stream scans and optimize queue queries
This commit is contained in:
@@ -81,7 +81,7 @@ class DownloadHistoryItem {
|
||||
'safFileName': safFileName,
|
||||
'safRepaired': safRepaired,
|
||||
'service': service,
|
||||
'downloadedAt': downloadedAt.toIso8601String(),
|
||||
'downloadedAt': downloadedAt.toUtc().toIso8601String(),
|
||||
'isrc': isrc,
|
||||
'spotifyId': spotifyId,
|
||||
'trackNumber': trackNumber,
|
||||
@@ -116,7 +116,7 @@ class DownloadHistoryItem {
|
||||
safFileName: json['safFileName'] as String?,
|
||||
safRepaired: json['safRepaired'] == true,
|
||||
service: json['service'] as String,
|
||||
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
|
||||
downloadedAt: DateTime.parse(json['downloadedAt'] as String).toLocal(),
|
||||
isrc: json['isrc'] as String?,
|
||||
spotifyId: json['spotifyId'] as String?,
|
||||
trackNumber: json['trackNumber'] as int?,
|
||||
|
||||
@@ -158,9 +158,7 @@ List<Track> normalizeBatchAlbumArtists(List<Track> tracks) {
|
||||
final currentArtist = normalizeOptionalString(tracks[index].albumArtist);
|
||||
if (currentArtist == canonicalArtist) continue;
|
||||
normalized ??= List<Track>.of(tracks);
|
||||
normalized[index] = tracks[index].copyWith(
|
||||
albumArtist: canonicalArtist,
|
||||
);
|
||||
normalized[index] = tracks[index].copyWith(albumArtist: canonicalArtist);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,9 +207,7 @@ String? _sharedBatchAlbumArtist(List<Track> tracks) {
|
||||
credits.add(names);
|
||||
}
|
||||
|
||||
final sharedKeys = credits.first
|
||||
.map((name) => name.toLowerCase())
|
||||
.toSet();
|
||||
final sharedKeys = credits.first.map((name) => name.toLowerCase()).toSet();
|
||||
for (final credit in credits.skip(1)) {
|
||||
final keys = credit.map((name) => name.toLowerCase()).toSet();
|
||||
sharedKeys.removeWhere((name) => !keys.contains(name));
|
||||
@@ -1453,7 +1449,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
var settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
var isSafMode = _isSafMode(settings);
|
||||
var iosDownloadBookmarkActive = false;
|
||||
IosSecurityScopedAccess? iosDownloadBookmarkAccess;
|
||||
|
||||
// Validate SAF before handing the batch to either queue implementation.
|
||||
// Never silently redirect a user-selected SAF destination into private app
|
||||
@@ -1630,11 +1626,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (!isSafMode &&
|
||||
Platform.isIOS &&
|
||||
settings.downloadDirectoryBookmark.isNotEmpty) {
|
||||
final resolvedPath = await PlatformBridge.startAccessingIosBookmark(
|
||||
settings.downloadDirectoryBookmark,
|
||||
);
|
||||
iosDownloadBookmarkAccess =
|
||||
await PlatformBridge.startAccessingIosBookmark(
|
||||
settings.downloadDirectoryBookmark,
|
||||
);
|
||||
final resolvedPath = iosDownloadBookmarkAccess?.path;
|
||||
if (resolvedPath != null && resolvedPath.isNotEmpty) {
|
||||
iosDownloadBookmarkActive = true;
|
||||
if (resolvedPath != state.outputDir) {
|
||||
_log.i('Resolved iOS download bookmark path: $resolvedPath');
|
||||
state = state.copyWith(outputDir: resolvedPath);
|
||||
@@ -1662,9 +1659,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
try {
|
||||
await _runQueueLoop();
|
||||
} finally {
|
||||
if (iosDownloadBookmarkActive) {
|
||||
await PlatformBridge.stopAccessingIosBookmark();
|
||||
iosDownloadBookmarkActive = false;
|
||||
if (iosDownloadBookmarkAccess != null) {
|
||||
await PlatformBridge.stopAccessingIosBookmark(
|
||||
iosDownloadBookmarkAccess,
|
||||
);
|
||||
iosDownloadBookmarkAccess = null;
|
||||
}
|
||||
}
|
||||
final stoppedWhilePaused = state.isPaused;
|
||||
|
||||
@@ -6,8 +6,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/library_collections_database.dart';
|
||||
|
||||
const _playlistCoverMaxDimension = 1024;
|
||||
const _playlistCoverMaxStoredBytes = 2 * 1024 * 1024;
|
||||
|
||||
String trackCollectionKey(Track track) {
|
||||
final isrc = track.isrc?.trim();
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
@@ -986,12 +990,11 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null) return;
|
||||
|
||||
final coversDir = await _playlistCoversDir();
|
||||
final ext = p.extension(sourceFilePath).toLowerCase();
|
||||
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
||||
if (playlist.coverImagePath == destPath) return;
|
||||
|
||||
await File(sourceFilePath).copy(destPath);
|
||||
final previousCoverPath = playlist.coverImagePath;
|
||||
final destPath = await _normalizePlaylistCoverFile(
|
||||
playlistId,
|
||||
sourceFilePath,
|
||||
);
|
||||
|
||||
final now = DateTime.now();
|
||||
await _db.updatePlaylistCover(
|
||||
@@ -1004,6 +1007,78 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
return playlist.copyWith(coverImagePath: () => destPath, updatedAt: now);
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
if (previousCoverPath != null && previousCoverPath != destPath) {
|
||||
try {
|
||||
final previous = File(previousCoverPath);
|
||||
if (await previous.exists()) await previous.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _normalizePlaylistCoverFile(
|
||||
String playlistId,
|
||||
String sourceFilePath,
|
||||
) async {
|
||||
final coversDir = await _playlistCoversDir();
|
||||
final destPath = p.join(coversDir.path, '$playlistId.jpg');
|
||||
final tempPath = p.join(
|
||||
coversDir.path,
|
||||
'.$playlistId.${DateTime.now().microsecondsSinceEpoch}.jpg',
|
||||
);
|
||||
try {
|
||||
final dimensions = await FFmpegService.probeImageDimensions(
|
||||
sourceFilePath,
|
||||
);
|
||||
final longestEdge = dimensions == null
|
||||
? _playlistCoverMaxDimension
|
||||
: (dimensions.width > dimensions.height
|
||||
? dimensions.width
|
||||
: dimensions.height);
|
||||
var targetDimension = longestEdge
|
||||
.clamp(64, _playlistCoverMaxDimension)
|
||||
.toInt();
|
||||
while (true) {
|
||||
final resized = await FFmpegService.resizeCoverArt(
|
||||
inputPath: sourceFilePath,
|
||||
outputPath: tempPath,
|
||||
maxDimension: targetDimension,
|
||||
);
|
||||
if (!resized) {
|
||||
throw StateError('Unable to normalize playlist cover');
|
||||
}
|
||||
if (await File(tempPath).length() <= _playlistCoverMaxStoredBytes) {
|
||||
break;
|
||||
}
|
||||
if (targetDimension <= 64) {
|
||||
throw StateError('Normalized playlist cover exceeds the size limit');
|
||||
}
|
||||
targetDimension = (targetDimension * 3 ~/ 4)
|
||||
.clamp(64, targetDimension)
|
||||
.toInt();
|
||||
}
|
||||
final destination = File(destPath);
|
||||
if (await destination.exists()) await destination.delete();
|
||||
await File(tempPath).rename(destPath);
|
||||
return destPath;
|
||||
} finally {
|
||||
try {
|
||||
final temp = File(tempPath);
|
||||
if (await temp.exists()) await temp.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _playlistCoverNeedsNormalization(String path) async {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return false;
|
||||
if (p.extension(path).toLowerCase() != '.jpg' ||
|
||||
await file.length() > _playlistCoverMaxStoredBytes) {
|
||||
return true;
|
||||
}
|
||||
final dimensions = await FFmpegService.probeImageDimensions(path);
|
||||
return dimensions == null ||
|
||||
dimensions.width > _playlistCoverMaxDimension ||
|
||||
dimensions.height > _playlistCoverMaxDimension;
|
||||
}
|
||||
|
||||
Future<void> removePlaylistCover(String playlistId) async {
|
||||
@@ -1046,10 +1121,15 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
Future<Map<String, Map<String, String>>> exportPlaylistCovers() async {
|
||||
await _ensureLoaded();
|
||||
final covers = <String, Map<String, String>>{};
|
||||
for (final playlist in state.playlists) {
|
||||
final path = playlist.coverImagePath;
|
||||
final playlists = List<UserPlaylistCollection>.of(state.playlists);
|
||||
for (final playlist in playlists) {
|
||||
var path = playlist.coverImagePath;
|
||||
if (path == null || path.isEmpty) continue;
|
||||
try {
|
||||
if (await _playlistCoverNeedsNormalization(path)) {
|
||||
await setPlaylistCover(playlist.id, path);
|
||||
path = state.playlistById(playlist.id)?.coverImagePath ?? path;
|
||||
}
|
||||
final file = File(path);
|
||||
if (!await file.exists()) continue;
|
||||
final bytes = await file.readAsBytes();
|
||||
@@ -1092,9 +1172,22 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final ext = (coverEntry['ext'] as String?) ?? '.jpg';
|
||||
if (data != null && data.isNotEmpty) {
|
||||
try {
|
||||
final destPath = p.join(coversDir.path, '$id$ext');
|
||||
await File(destPath).writeAsBytes(base64Decode(data));
|
||||
newCoverPath = destPath;
|
||||
final sourcePath = p.join(
|
||||
coversDir.path,
|
||||
'.$id.restore${ext.startsWith('.') ? ext : '.$ext'}',
|
||||
);
|
||||
final source = File(sourcePath);
|
||||
await source.writeAsBytes(base64Decode(data), flush: true);
|
||||
try {
|
||||
newCoverPath = await _normalizePlaylistCoverFile(
|
||||
id,
|
||||
sourcePath,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
if (await source.exists()) await source.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {
|
||||
newCoverPath = null;
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
bool _hasLoadedFromDatabase = false;
|
||||
Future<void>? _loadFuture;
|
||||
bool _scanCancelRequested = false;
|
||||
bool _scanInProgress = false;
|
||||
static const _scanNotificationHeartbeat = Duration(seconds: 4);
|
||||
int _lastScanNotificationPercent = -1;
|
||||
int _lastScanNotificationTotalFiles = -1;
|
||||
@@ -237,16 +238,73 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<({int inserted, int skipped})?> _replaceFromFullScanStream({
|
||||
required String folderPath,
|
||||
required bool isSaf,
|
||||
required Set<String> downloadedPathKeys,
|
||||
}) async {
|
||||
if (_scanCancelRequested) return null;
|
||||
final scanFile = isSaf
|
||||
? await PlatformBridge.scanSafTreeToNDJSONFile(
|
||||
folderPath,
|
||||
isCancelled: () => _scanCancelRequested,
|
||||
)
|
||||
: await PlatformBridge.scanLibraryFolderToNDJSONFile(
|
||||
folderPath,
|
||||
isCancelled: () => _scanCancelRequested,
|
||||
);
|
||||
var skipped = 0;
|
||||
try {
|
||||
if (_scanCancelRequested) return null;
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: true,
|
||||
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||
scanCurrentFile: null,
|
||||
);
|
||||
Stream<Map<String, dynamic>> filteredRows() async* {
|
||||
var decodedRows = 0;
|
||||
await for (final json in scanFile.rows()) {
|
||||
if (_scanCancelRequested) {
|
||||
throw StateError('Library scan cancelled during ingestion');
|
||||
}
|
||||
decodedRows++;
|
||||
final filePath = json['filePath'] as String?;
|
||||
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
yield json;
|
||||
}
|
||||
if (decodedRows != scanFile.expectedCount) {
|
||||
throw FormatException(
|
||||
'Library scan row count mismatch: decoded $decodedRows, '
|
||||
'expected ${scanFile.expectedCount}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final inserted = await _db.replaceAllStream(filteredRows());
|
||||
_log.i(
|
||||
'Stream-ingested $inserted/${scanFile.expectedCount} scan rows '
|
||||
'($skipped downloads excluded)',
|
||||
);
|
||||
return (inserted: inserted, skipped: skipped);
|
||||
} finally {
|
||||
await scanFile.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startScan(
|
||||
String folderPath, {
|
||||
bool forceFullScan = false,
|
||||
String? iosBookmark,
|
||||
}) async {
|
||||
if (state.isScanning) {
|
||||
if (_scanInProgress || state.isScanning) {
|
||||
_log.w('Scan already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
_scanInProgress = true;
|
||||
_scanCancelRequested = false;
|
||||
_log.i(
|
||||
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
|
||||
@@ -287,13 +345,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_startProgressPolling();
|
||||
|
||||
String? resolvedPath;
|
||||
bool didStartSecurityAccess = false;
|
||||
IosSecurityScopedAccess? securityAccess;
|
||||
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||
resolvedPath = await PlatformBridge.startAccessingIosBookmark(
|
||||
securityAccess = await PlatformBridge.startAccessingIosBookmark(
|
||||
iosBookmark,
|
||||
);
|
||||
if (resolvedPath != null) {
|
||||
didStartSecurityAccess = true;
|
||||
resolvedPath = securityAccess?.path;
|
||||
if (securityAccess != null) {
|
||||
_log.i('Started iOS security-scoped access: $resolvedPath');
|
||||
} else {
|
||||
_log.w(
|
||||
@@ -326,11 +384,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'(${downloadedPathKeys.length} path keys)',
|
||||
);
|
||||
|
||||
if (forceFullScan) {
|
||||
final results = isSaf
|
||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||
if (_scanCancelRequested) {
|
||||
final useStreamingFullScan = forceFullScan || await _db.getCount() == 0;
|
||||
if (useStreamingFullScan) {
|
||||
final scanResult = await _replaceFromFullScanStream(
|
||||
folderPath: effectiveFolderPath,
|
||||
isSaf: isSaf,
|
||||
downloadedPathKeys: downloadedPathKeys,
|
||||
);
|
||||
if (scanResult == null || _scanCancelRequested) {
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
@@ -346,24 +407,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
scanCurrentFile: null,
|
||||
);
|
||||
|
||||
final items = <LocalLibraryItem>[];
|
||||
int skippedDownloads = 0;
|
||||
for (final json in results) {
|
||||
final filePath = json['filePath'] as String?;
|
||||
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
final item = LocalLibraryItem.fromJson(json);
|
||||
items.add(item);
|
||||
}
|
||||
final skippedDownloads = scanResult.skipped;
|
||||
|
||||
if (skippedDownloads > 0) {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
await _db.replaceAll(items.map((e) => e.toJson()).toList());
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -421,6 +470,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
Map<String, dynamic> result;
|
||||
try {
|
||||
if (_scanCancelRequested) {
|
||||
throw StateError('Library scan cancelled before native scan');
|
||||
}
|
||||
if (isSaf) {
|
||||
result = useSnapshotBridge && snapshotPath != null
|
||||
? await PlatformBridge.scanSafTreeIncrementalFromSnapshot(
|
||||
@@ -564,6 +616,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
if (_scanCancelRequested) {
|
||||
_log.i('Library scan cancelled');
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
await _showScanCancelledNotification();
|
||||
return;
|
||||
}
|
||||
_log.e('Library scan failed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
@@ -572,11 +634,12 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
await _showScanFailedNotification(e.toString());
|
||||
} finally {
|
||||
if (didStartSecurityAccess) {
|
||||
await PlatformBridge.stopAccessingIosBookmark();
|
||||
if (securityAccess != null) {
|
||||
await PlatformBridge.stopAccessingIosBookmark(securityAccess);
|
||||
_log.i('Stopped iOS security-scoped access');
|
||||
}
|
||||
_stopProgressPolling();
|
||||
_scanInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,6 +656,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
|
||||
if (_scanCancelRequested) return;
|
||||
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
|
||||
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
|
||||
0.0,
|
||||
@@ -683,11 +747,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Cancelling library scan');
|
||||
_scanCancelRequested = true;
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
state = state.copyWith(
|
||||
isScanning: false,
|
||||
scanIsFinalizing: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
state = state.copyWith(scanIsFinalizing: false, scanWasCancelled: true);
|
||||
_stopProgressPolling();
|
||||
await _showScanCancelledNotification();
|
||||
}
|
||||
@@ -761,13 +821,15 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
Future<int> cleanupMissingFiles({String? iosBookmark}) async {
|
||||
bool didStartSecurityAccess = false;
|
||||
IosSecurityScopedAccess? securityAccess;
|
||||
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||
final resolved = await PlatformBridge.startAccessingIosBookmark(
|
||||
securityAccess = await PlatformBridge.startAccessingIosBookmark(
|
||||
iosBookmark,
|
||||
);
|
||||
if (resolved != null) {
|
||||
didStartSecurityAccess = true;
|
||||
if (securityAccess == null) {
|
||||
throw const FileSystemException(
|
||||
'Cannot clean the library without folder access',
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -777,8 +839,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
return removed;
|
||||
} finally {
|
||||
if (didStartSecurityAccess) {
|
||||
await PlatformBridge.stopAccessingIosBookmark();
|
||||
if (securityAccess != null) {
|
||||
await PlatformBridge.stopAccessingIosBookmark(securityAccess);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user