perf(library): stream scans and optimize queue queries

This commit is contained in:
zarzet
2026-08-18 11:03:05 +07:00
parent 29609b6084
commit 7fc27e8314
28 changed files with 2145 additions and 338 deletions
+2 -2
View File
@@ -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?,
+13 -14
View File
@@ -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;
+104 -11
View File
@@ -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;
}
+98 -36
View File
@@ -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);
}
}
}