From b81bd5e8aa5c48ebc4e3d567ab6167ed5d2f912d Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 26 Jul 2026 22:56:57 +0700 Subject: [PATCH] refactor(history): split models and startup maintenance into part files --- lib/providers/download_history_models.dart | 285 ++++++ lib/providers/download_history_provider.dart | 878 +----------------- ...download_history_provider_maintenance.dart | 614 ++++++++++++ 3 files changed, 902 insertions(+), 875 deletions(-) create mode 100644 lib/providers/download_history_models.dart create mode 100644 lib/providers/download_history_provider_maintenance.dart diff --git a/lib/providers/download_history_models.dart b/lib/providers/download_history_models.dart new file mode 100644 index 00000000..4f5cb3fd --- /dev/null +++ b/lib/providers/download_history_models.dart @@ -0,0 +1,285 @@ +part of 'download_history_provider.dart'; + +class DownloadHistoryItem { + final String id; + final String trackName; + final String artistName; + final String albumName; + final String? albumArtist; + final String? coverUrl; + final String filePath; + final String? storageMode; + final String? downloadTreeUri; + final String? safRelativeDir; + final String? safFileName; + final bool safRepaired; + final String service; + final DateTime downloadedAt; + final String? isrc; + final String? spotifyId; + final int? trackNumber; + final int? totalTracks; + final int? discNumber; + final int? totalDiscs; + final int? duration; + final String? releaseDate; + final String? quality; + final int? bitDepth; + final int? sampleRate; + final int? bitrate; + final String? format; + final String? genre; + final String? composer; + final String? label; + final String? copyright; + + const DownloadHistoryItem({ + required this.id, + required this.trackName, + required this.artistName, + required this.albumName, + this.albumArtist, + this.coverUrl, + required this.filePath, + this.storageMode, + this.downloadTreeUri, + this.safRelativeDir, + this.safFileName, + this.safRepaired = false, + required this.service, + required this.downloadedAt, + this.isrc, + this.spotifyId, + this.trackNumber, + this.totalTracks, + this.discNumber, + this.totalDiscs, + this.duration, + this.releaseDate, + this.quality, + this.bitDepth, + this.sampleRate, + this.bitrate, + this.format, + this.genre, + this.composer, + this.label, + this.copyright, + }); + + Map toJson() => { + 'id': id, + 'trackName': trackName, + 'artistName': artistName, + 'albumName': albumName, + 'albumArtist': albumArtist, + 'coverUrl': coverUrl, + 'filePath': filePath, + 'storageMode': storageMode, + 'downloadTreeUri': downloadTreeUri, + 'safRelativeDir': safRelativeDir, + 'safFileName': safFileName, + 'safRepaired': safRepaired, + 'service': service, + 'downloadedAt': downloadedAt.toIso8601String(), + 'isrc': isrc, + 'spotifyId': spotifyId, + 'trackNumber': trackNumber, + 'totalTracks': totalTracks, + 'discNumber': discNumber, + 'totalDiscs': totalDiscs, + 'duration': duration, + 'releaseDate': releaseDate, + 'quality': quality, + 'bitDepth': bitDepth, + 'sampleRate': sampleRate, + 'bitrate': bitrate, + 'format': format, + 'genre': genre, + 'composer': composer, + 'label': label, + 'copyright': copyright, + }; + + factory DownloadHistoryItem.fromJson(Map json) => + DownloadHistoryItem( + id: json['id'] as String, + trackName: json['trackName'] as String, + artistName: json['artistName'] as String, + albumName: json['albumName'] as String, + albumArtist: normalizeOptionalString(json['albumArtist'] as String?), + coverUrl: normalizeCoverReference(json['coverUrl']?.toString()), + filePath: json['filePath'] as String, + storageMode: json['storageMode'] as String?, + downloadTreeUri: json['downloadTreeUri'] as String?, + safRelativeDir: json['safRelativeDir'] as String?, + safFileName: json['safFileName'] as String?, + safRepaired: json['safRepaired'] == true, + service: json['service'] as String, + downloadedAt: DateTime.parse(json['downloadedAt'] as String), + isrc: json['isrc'] as String?, + spotifyId: json['spotifyId'] as String?, + trackNumber: json['trackNumber'] as int?, + totalTracks: json['totalTracks'] as int?, + discNumber: json['discNumber'] as int?, + totalDiscs: json['totalDiscs'] as int?, + duration: json['duration'] as int?, + releaseDate: json['releaseDate'] as String?, + quality: json['quality'] as String?, + bitDepth: json['bitDepth'] as int?, + sampleRate: json['sampleRate'] as int?, + bitrate: (json['bitrate'] as num?)?.toInt(), + format: json['format'] as String?, + genre: json['genre'] as String?, + composer: json['composer'] as String?, + label: json['label'] as String?, + copyright: json['copyright'] as String?, + ); + + DownloadHistoryItem copyWith({ + String? trackName, + String? artistName, + String? albumName, + String? albumArtist, + String? coverUrl, + String? filePath, + String? storageMode, + String? downloadTreeUri, + String? safRelativeDir, + String? safFileName, + bool? safRepaired, + String? isrc, + String? spotifyId, + int? trackNumber, + int? totalTracks, + int? discNumber, + int? totalDiscs, + int? duration, + String? releaseDate, + String? quality, + int? bitDepth, + int? sampleRate, + int? bitrate, + String? format, + String? genre, + String? composer, + String? label, + String? copyright, + }) { + return DownloadHistoryItem( + id: id, + trackName: trackName ?? this.trackName, + artistName: artistName ?? this.artistName, + albumName: albumName ?? this.albumName, + albumArtist: albumArtist ?? this.albumArtist, + coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl), + filePath: filePath ?? this.filePath, + storageMode: storageMode ?? this.storageMode, + downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, + safRelativeDir: safRelativeDir ?? this.safRelativeDir, + safFileName: safFileName ?? this.safFileName, + safRepaired: safRepaired ?? this.safRepaired, + service: service, + downloadedAt: downloadedAt, + isrc: isrc ?? this.isrc, + spotifyId: spotifyId ?? this.spotifyId, + trackNumber: trackNumber ?? this.trackNumber, + totalTracks: totalTracks ?? this.totalTracks, + discNumber: discNumber ?? this.discNumber, + totalDiscs: totalDiscs ?? this.totalDiscs, + duration: duration ?? this.duration, + releaseDate: releaseDate ?? this.releaseDate, + quality: quality ?? this.quality, + bitDepth: bitDepth ?? this.bitDepth, + sampleRate: sampleRate ?? this.sampleRate, + bitrate: bitrate ?? this.bitrate, + format: format ?? this.format, + genre: genre ?? this.genre, + composer: composer ?? this.composer, + label: label ?? this.label, + copyright: copyright ?? this.copyright, + ); + } +} + +Map _indexRecentHistoryItems( + Iterable items, + String? Function(DownloadHistoryItem item) keyOf, +) { + final index = {}; + for (final item in items) { + final key = keyOf(item); + if (key != null && key.isNotEmpty) { + index.putIfAbsent(key, () => item); + } + } + return index; +} + +class DownloadHistoryState { + final List items; + final int totalCount; + final int loadedIndexVersion; + final List _lookupItems; + final Map _bySpotifyId; + final Map _byIsrc; + final Map _byTrackArtistKey; + + DownloadHistoryState({ + this.items = const [], + this.totalCount = 0, + this.loadedIndexVersion = 0, + List? lookupItems, + }) : _lookupItems = List.unmodifiable(lookupItems ?? items), + _bySpotifyId = _indexRecentHistoryItems( + lookupItems ?? items, + (item) => item.spotifyId, + ), + _byIsrc = _indexRecentHistoryItems( + lookupItems ?? items, + (item) => item.isrc, + ), + _byTrackArtistKey = _indexRecentHistoryItems( + lookupItems ?? items, + (item) => _trackArtistKey(item.trackName, item.artistName), + ); + + static String _trackArtistKey(String trackName, String artistName) { + final normalizedTrack = trackName.trim().toLowerCase(); + if (normalizedTrack.isEmpty) return ''; + final normalizedArtist = artistName.trim().toLowerCase(); + return '$normalizedTrack|$normalizedArtist'; + } + + bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId); + + DownloadHistoryItem? getBySpotifyId(String spotifyId) => + _bySpotifyId[spotifyId]; + + DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc]; + + DownloadHistoryItem? findByTrackAndArtist( + String trackName, + String artistName, + ) { + final key = _trackArtistKey(trackName, artistName); + if (key.isEmpty) return null; + return _byTrackArtistKey[key]; + } + + List get lookupItems => _lookupItems; + + DownloadHistoryState copyWith({ + List? items, + int? totalCount, + int? loadedIndexVersion, + List? lookupItems, + }) { + return DownloadHistoryState( + items: items ?? this.items, + totalCount: totalCount ?? this.totalCount, + loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion, + lookupItems: lookupItems ?? _lookupItems, + ); + } +} diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index c5203b75..6fbfa147 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -13,6 +13,9 @@ import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/path_match_keys.dart'; +part 'download_history_models.dart'; +part 'download_history_provider_maintenance.dart'; + final _historyLog = AppLogger('DownloadHistory'); typedef StartupOrphanDecision = ({ @@ -54,290 +57,6 @@ String? resolvePersistedHistoryQuality({ normalizeOptionalString(existing); } -class DownloadHistoryItem { - final String id; - final String trackName; - final String artistName; - final String albumName; - final String? albumArtist; - final String? coverUrl; - final String filePath; - final String? storageMode; - final String? downloadTreeUri; - final String? safRelativeDir; - final String? safFileName; - final bool safRepaired; - final String service; - final DateTime downloadedAt; - final String? isrc; - final String? spotifyId; - final int? trackNumber; - final int? totalTracks; - final int? discNumber; - final int? totalDiscs; - final int? duration; - final String? releaseDate; - final String? quality; - final int? bitDepth; - final int? sampleRate; - final int? bitrate; - final String? format; - final String? genre; - final String? composer; - final String? label; - final String? copyright; - - const DownloadHistoryItem({ - required this.id, - required this.trackName, - required this.artistName, - required this.albumName, - this.albumArtist, - this.coverUrl, - required this.filePath, - this.storageMode, - this.downloadTreeUri, - this.safRelativeDir, - this.safFileName, - this.safRepaired = false, - required this.service, - required this.downloadedAt, - this.isrc, - this.spotifyId, - this.trackNumber, - this.totalTracks, - this.discNumber, - this.totalDiscs, - this.duration, - this.releaseDate, - this.quality, - this.bitDepth, - this.sampleRate, - this.bitrate, - this.format, - this.genre, - this.composer, - this.label, - this.copyright, - }); - - Map toJson() => { - 'id': id, - 'trackName': trackName, - 'artistName': artistName, - 'albumName': albumName, - 'albumArtist': albumArtist, - 'coverUrl': coverUrl, - 'filePath': filePath, - 'storageMode': storageMode, - 'downloadTreeUri': downloadTreeUri, - 'safRelativeDir': safRelativeDir, - 'safFileName': safFileName, - 'safRepaired': safRepaired, - 'service': service, - 'downloadedAt': downloadedAt.toIso8601String(), - 'isrc': isrc, - 'spotifyId': spotifyId, - 'trackNumber': trackNumber, - 'totalTracks': totalTracks, - 'discNumber': discNumber, - 'totalDiscs': totalDiscs, - 'duration': duration, - 'releaseDate': releaseDate, - 'quality': quality, - 'bitDepth': bitDepth, - 'sampleRate': sampleRate, - 'bitrate': bitrate, - 'format': format, - 'genre': genre, - 'composer': composer, - 'label': label, - 'copyright': copyright, - }; - - factory DownloadHistoryItem.fromJson(Map json) => - DownloadHistoryItem( - id: json['id'] as String, - trackName: json['trackName'] as String, - artistName: json['artistName'] as String, - albumName: json['albumName'] as String, - albumArtist: normalizeOptionalString(json['albumArtist'] as String?), - coverUrl: normalizeCoverReference(json['coverUrl']?.toString()), - filePath: json['filePath'] as String, - storageMode: json['storageMode'] as String?, - downloadTreeUri: json['downloadTreeUri'] as String?, - safRelativeDir: json['safRelativeDir'] as String?, - safFileName: json['safFileName'] as String?, - safRepaired: json['safRepaired'] == true, - service: json['service'] as String, - downloadedAt: DateTime.parse(json['downloadedAt'] as String), - isrc: json['isrc'] as String?, - spotifyId: json['spotifyId'] as String?, - trackNumber: json['trackNumber'] as int?, - totalTracks: json['totalTracks'] as int?, - discNumber: json['discNumber'] as int?, - totalDiscs: json['totalDiscs'] as int?, - duration: json['duration'] as int?, - releaseDate: json['releaseDate'] as String?, - quality: json['quality'] as String?, - bitDepth: json['bitDepth'] as int?, - sampleRate: json['sampleRate'] as int?, - bitrate: (json['bitrate'] as num?)?.toInt(), - format: json['format'] as String?, - genre: json['genre'] as String?, - composer: json['composer'] as String?, - label: json['label'] as String?, - copyright: json['copyright'] as String?, - ); - - DownloadHistoryItem copyWith({ - String? trackName, - String? artistName, - String? albumName, - String? albumArtist, - String? coverUrl, - String? filePath, - String? storageMode, - String? downloadTreeUri, - String? safRelativeDir, - String? safFileName, - bool? safRepaired, - String? isrc, - String? spotifyId, - int? trackNumber, - int? totalTracks, - int? discNumber, - int? totalDiscs, - int? duration, - String? releaseDate, - String? quality, - int? bitDepth, - int? sampleRate, - int? bitrate, - String? format, - String? genre, - String? composer, - String? label, - String? copyright, - }) { - return DownloadHistoryItem( - id: id, - trackName: trackName ?? this.trackName, - artistName: artistName ?? this.artistName, - albumName: albumName ?? this.albumName, - albumArtist: albumArtist ?? this.albumArtist, - coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl), - filePath: filePath ?? this.filePath, - storageMode: storageMode ?? this.storageMode, - downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, - safRelativeDir: safRelativeDir ?? this.safRelativeDir, - safFileName: safFileName ?? this.safFileName, - safRepaired: safRepaired ?? this.safRepaired, - service: service, - downloadedAt: downloadedAt, - isrc: isrc ?? this.isrc, - spotifyId: spotifyId ?? this.spotifyId, - trackNumber: trackNumber ?? this.trackNumber, - totalTracks: totalTracks ?? this.totalTracks, - discNumber: discNumber ?? this.discNumber, - totalDiscs: totalDiscs ?? this.totalDiscs, - duration: duration ?? this.duration, - releaseDate: releaseDate ?? this.releaseDate, - quality: quality ?? this.quality, - bitDepth: bitDepth ?? this.bitDepth, - sampleRate: sampleRate ?? this.sampleRate, - bitrate: bitrate ?? this.bitrate, - format: format ?? this.format, - genre: genre ?? this.genre, - composer: composer ?? this.composer, - label: label ?? this.label, - copyright: copyright ?? this.copyright, - ); - } -} - -Map _indexRecentHistoryItems( - Iterable items, - String? Function(DownloadHistoryItem item) keyOf, -) { - final index = {}; - for (final item in items) { - final key = keyOf(item); - if (key != null && key.isNotEmpty) { - index.putIfAbsent(key, () => item); - } - } - return index; -} - -class DownloadHistoryState { - final List items; - final int totalCount; - final int loadedIndexVersion; - final List _lookupItems; - final Map _bySpotifyId; - final Map _byIsrc; - final Map _byTrackArtistKey; - - DownloadHistoryState({ - this.items = const [], - this.totalCount = 0, - this.loadedIndexVersion = 0, - List? lookupItems, - }) : _lookupItems = List.unmodifiable(lookupItems ?? items), - _bySpotifyId = _indexRecentHistoryItems( - lookupItems ?? items, - (item) => item.spotifyId, - ), - _byIsrc = _indexRecentHistoryItems( - lookupItems ?? items, - (item) => item.isrc, - ), - _byTrackArtistKey = _indexRecentHistoryItems( - lookupItems ?? items, - (item) => _trackArtistKey(item.trackName, item.artistName), - ); - - static String _trackArtistKey(String trackName, String artistName) { - final normalizedTrack = trackName.trim().toLowerCase(); - if (normalizedTrack.isEmpty) return ''; - final normalizedArtist = artistName.trim().toLowerCase(); - return '$normalizedTrack|$normalizedArtist'; - } - - bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId); - - DownloadHistoryItem? getBySpotifyId(String spotifyId) => - _bySpotifyId[spotifyId]; - - DownloadHistoryItem? getByIsrc(String isrc) => _byIsrc[isrc]; - - DownloadHistoryItem? findByTrackAndArtist( - String trackName, - String artistName, - ) { - final key = _trackArtistKey(trackName, artistName); - if (key.isEmpty) return null; - return _byTrackArtistKey[key]; - } - - List get lookupItems => _lookupItems; - - DownloadHistoryState copyWith({ - List? items, - int? totalCount, - int? loadedIndexVersion, - List? lookupItems, - }) { - return DownloadHistoryState( - items: items ?? this.items, - totalCount: totalCount ?? this.totalCount, - loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion, - lookupItems: lookupItems ?? _lookupItems, - ); - } -} - class DownloadHistoryNotifier extends Notifier { static const int _initialHistoryLoadLimit = 100; static const int _safRepairBatchSize = 20; @@ -418,597 +137,6 @@ class DownloadHistoryNotifier extends Notifier { } } - void _scheduleStartupMaintenance(List initialItems) { - if (_startupMaintenanceScheduled) { - return; - } - _startupMaintenanceScheduled = true; - - unawaited( - Future.delayed(_startupMaintenanceDelay, () async { - try { - final prefs = await SharedPreferences.getInstance(); - - if (Platform.isAndroid) { - await _repairMissingSafEntries( - initialItems, - maxItems: _safRepairMaxPerLaunch, - prefs: prefs, - ); - await Future.delayed(_startupMaintenanceStepGap); - } - - await _cleanupOrphanedDownloadsIncremental( - maxItems: _orphanCleanupMaxPerLaunch, - prefs: prefs, - ); - await Future.delayed(_startupMaintenanceStepGap); - - final currentItems = state.items; - if (currentItems.isNotEmpty) { - await _backfillAudioMetadata( - currentItems, - maxItems: _audioMetadataBackfillMaxPerLaunch, - prefs: prefs, - ); - } - } catch (e, stack) { - _historyLog.w('Startup history maintenance failed: $e'); - _historyLog.d('$stack'); - } - }), - ); - } - - int _readStartupCursor(SharedPreferences prefs, String key, int totalCount) { - if (totalCount <= 0) { - return 0; - } - final cursor = prefs.getInt(key) ?? 0; - if (cursor < 0 || cursor >= totalCount) { - return 0; - } - return cursor; - } - - Future _writeStartupCursor( - SharedPreferences prefs, - String key, - int nextCursor, - int totalCount, - ) async { - if (totalCount <= 0 || nextCursor <= 0 || nextCursor >= totalCount) { - await prefs.remove(key); - return; - } - await prefs.setInt(key, nextCursor); - } - - String _fileNameFromUri(String uri) { - try { - final parsed = Uri.parse(uri); - if (parsed.pathSegments.isNotEmpty) { - return Uri.decodeComponent(parsed.pathSegments.last); - } - } catch (_) {} - return ''; - } - - List _conversionRenameCandidates( - String fileName, { - bool includeAlternateExtensions = false, - }) { - if (fileName.trim().isEmpty) return const []; - final dotIndex = fileName.lastIndexOf('.'); - if (dotIndex < 0) return [fileName]; - final baseName = fileName.substring(0, dotIndex); - final extension = fileName.substring(dotIndex); - final plainBase = baseName.endsWith('_converted') - ? baseName.substring(0, baseName.length - '_converted'.length) - : baseName; - return { - fileName, - if (plainBase != baseName) '$plainBase$extension', - if (plainBase == baseName) '${baseName}_converted$extension', - if (includeAlternateExtensions) - for (final audioExtension in _audioExtensions) - '$plainBase$audioExtension', - }.toList(growable: false); - } - - Future _repairMissingSafEntries( - List items, { - required int maxItems, - required SharedPreferences prefs, - }) async { - if (_isSafRepairInProgress || items.isEmpty) { - return; - } - _isSafRepairInProgress = true; - - final candidateIndexes = []; - for (var i = 0; i < items.length; i++) { - final item = items[i]; - if (item.storageMode != 'saf') continue; - if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) { - continue; - } - final hasFilePath = item.filePath.trim().isNotEmpty; - final hasSafFileName = - item.safFileName != null && item.safFileName!.trim().isNotEmpty; - if (!hasFilePath && !hasSafFileName) { - continue; - } - candidateIndexes.add(i); - } - - if (candidateIndexes.isEmpty) { - await prefs.remove(_startupSafRepairCursorKey); - _isSafRepairInProgress = false; - return; - } - - final startCursor = _readStartupCursor( - prefs, - _startupSafRepairCursorKey, - candidateIndexes.length, - ); - final endCursor = (startCursor + maxItems).clamp( - 0, - candidateIndexes.length, - ); - final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor); - - if (selectedIndexes.isEmpty) { - await prefs.remove(_startupSafRepairCursorKey); - _isSafRepairInProgress = false; - return; - } - - final updatedItems = [...items]; - final persistedUpdates = >[]; - var changed = false; - var repairedCount = 0; - var verifiedCount = 0; - - try { - for (var c = 0; c < selectedIndexes.length; c++) { - final i = selectedIndexes[c]; - final item = items[i]; - final rawPath = item.filePath.trim(); - final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath); - - if (isDirectSafUri) { - final exists = await fileExists(rawPath); - if (exists) { - final verified = item.copyWith( - safRepaired: true, - safFileName: item.safFileName ?? _fileNameFromUri(rawPath), - ); - updatedItems[i] = verified; - changed = true; - verifiedCount++; - persistedUpdates.add(verified.toJson()); - continue; - } - } - - var fallbackName = (item.safFileName ?? '').trim(); - if (fallbackName.isEmpty && isDirectSafUri) { - fallbackName = _fileNameFromUri(rawPath); - } - if (fallbackName.isEmpty) { - _historyLog.w('Missing SAF filename for history item: ${item.id}'); - continue; - } - - try { - Map? resolved; - String? resolvedFileName; - for (final candidate in _conversionRenameCandidates(fallbackName)) { - final candidateResult = await PlatformBridge.resolveSafFile( - treeUri: item.downloadTreeUri!, - relativeDir: item.safRelativeDir ?? '', - fileName: candidate, - ); - final candidateUri = (candidateResult['uri'] as String? ?? '') - .trim(); - if (candidateUri.isEmpty) continue; - resolved = candidateResult; - resolvedFileName = candidate; - break; - } - if (resolved == null || resolvedFileName == null) continue; - final newUri = (resolved['uri'] as String).trim(); - - final newRelativeDir = resolved['relative_dir'] as String?; - final updated = item.copyWith( - filePath: newUri, - safRelativeDir: - (newRelativeDir != null && newRelativeDir.isNotEmpty) - ? newRelativeDir - : item.safRelativeDir, - safFileName: resolvedFileName, - safRepaired: true, - ); - - updatedItems[i] = updated; - changed = true; - repairedCount++; - persistedUpdates.add(updated.toJson()); - } catch (e) { - _historyLog.w('Failed to repair SAF URI: $e'); - } - - if ((c + 1) % _safRepairBatchSize == 0) { - await Future.delayed(const Duration(milliseconds: 16)); - } - } - - if (changed) { - await _db.upsertBatch(persistedUpdates); - state = state.copyWith( - items: updatedItems, - loadedIndexVersion: state.loadedIndexVersion + 1, - lookupItems: _lookupItemsWithUpdates(updatedItems), - ); - _historyLog.i( - 'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}', - ); - } - await _writeStartupCursor( - prefs, - _startupSafRepairCursorKey, - endCursor, - candidateIndexes.length, - ); - } finally { - _isSafRepairInProgress = false; - } - } - - bool _supportsAudioMetadataProbe(String filePath) { - final trimmed = filePath.trim().toLowerCase(); - if (trimmed.isEmpty) return false; - if (trimmed.startsWith('content://')) return true; - return trimmed.endsWith('.flac') || - trimmed.endsWith('.m4a') || - trimmed.endsWith('.mp4') || - trimmed.endsWith('.aac') || - trimmed.endsWith('.mp3') || - trimmed.endsWith('.opus') || - trimmed.endsWith('.ogg'); - } - - bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) { - if (!_supportsAudioMetadataProbe(item.filePath)) { - return false; - } - - final trimmedPath = item.filePath.trim().toLowerCase(); - final hasResolvedSpecs = - item.bitDepth != null && - item.bitDepth! > 0 && - item.sampleRate != null && - item.sampleRate! > 0; - final needsFormatBackfill = normalizeOptionalString(item.format) == null; - final needsLosslessSpecProbe = - !hasResolvedSpecs && - (trimmedPath.endsWith('.flac') || - trimmedPath.endsWith('.m4a') || - trimmedPath.endsWith('.mp4') || - trimmedPath.endsWith('.aac') || - trimmedPath.startsWith('content://')); - - if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) { - final needsComposerBackfill = - normalizeOptionalString(item.composer) == null; - final needsDurationBackfill = item.duration == null || item.duration == 0; - final needsTrackNumberBackfill = item.trackNumber == null; - final needsTotalTracksBackfill = item.totalTracks == null; - final needsDiscNumberBackfill = item.discNumber == null; - final needsTotalDiscsBackfill = item.totalDiscs == null; - return needsComposerBackfill || - needsFormatBackfill || - needsDurationBackfill || - needsTrackNumberBackfill || - needsTotalTracksBackfill || - needsDiscNumberBackfill || - needsTotalDiscsBackfill; - } - - final needsComposerBackfill = - normalizeOptionalString(item.composer) == null; - final needsDurationBackfill = item.duration == null || item.duration == 0; - final needsTrackNumberBackfill = item.trackNumber == null; - final needsTotalTracksBackfill = item.totalTracks == null; - final needsDiscNumberBackfill = item.discNumber == null; - final needsTotalDiscsBackfill = item.totalDiscs == null; - return needsLosslessSpecProbe || - needsFormatBackfill || - isPlaceholderQualityLabel(item.quality) || - normalizeOptionalString(item.quality) == null || - needsComposerBackfill || - needsDurationBackfill || - needsTrackNumberBackfill || - needsTotalTracksBackfill || - needsDiscNumberBackfill || - needsTotalDiscsBackfill; - } - - /// Errors that indicate the file content itself cannot be parsed — as - /// opposed to transient conditions like a missing file or an unmounted - /// SAF volume. These never resolve on retry. - static bool _isPermanentProbeError(String error) { - final lower = error.toLowerCase(); - return lower.contains('failed to parse') || - lower.contains('head incorrect') || - lower.contains('invalid') || - lower.contains('not a '); - } - - Set _readAudioProbeFailedPaths(SharedPreferences prefs) { - final stored = prefs.getStringList(_audioProbeFailedPathsKey); - if (stored == null || stored.isEmpty) return {}; - return stored.toSet(); - } - - Future _rememberAudioProbeFailure( - SharedPreferences prefs, - String filePath, - ) async { - final normalized = filePath.trim(); - if (normalized.isEmpty) return; - final stored = prefs.getStringList(_audioProbeFailedPathsKey) ?? []; - if (stored.contains(normalized)) return; - stored.add(normalized); - while (stored.length > _audioProbeFailedPathsMax) { - stored.removeAt(0); - } - await prefs.setStringList(_audioProbeFailedPathsKey, stored); - } - - Future?> _probeAudioMetadata( - String filePath, { - String? fallbackQuality, - }) async { - if (!_supportsAudioMetadataProbe(filePath)) { - return null; - } - - try { - final result = await PlatformBridge.readFileMetadata(filePath); - final error = result['error']; - if (error != null) { - if (_isPermanentProbeError(error.toString())) { - // The file content itself is unparseable (e.g. an MP4 stream under - // a .flac name); retrying on every launch can never succeed. - return const {'permanent_failure': true}; - } - return null; - } - - final bitDepth = readPositiveInt(result['bit_depth']); - final sampleRate = readPositiveInt(result['sample_rate']); - final detectedFormat = normalizeAudioFormatValue( - result['audio_codec']?.toString() ?? result['format']?.toString(), - ); - final rawBitrateKbps = readPositiveBitrateKbps(result['bitrate']); - final bitrateKbps = isLossyAudioFormat(detectedFormat) - ? rawBitrateKbps - : null; - final quality = resolveDisplayQuality( - filePath: filePath, - detectedFormat: detectedFormat, - bitDepth: bitDepth, - sampleRate: sampleRate, - bitrateKbps: bitrateKbps, - storedQuality: fallbackQuality, - ); - final composer = normalizeOptionalString(result['composer']?.toString()); - final duration = readPositiveInt(result['duration']); - final trackNumber = readPositiveInt(result['track_number']); - final totalTracks = readPositiveInt(result['total_tracks']); - final discNumber = readPositiveInt(result['disc_number']); - final totalDiscs = readPositiveInt(result['total_discs']); - - if (quality == null && - bitDepth == null && - sampleRate == null && - bitrateKbps == null && - detectedFormat == null && - composer == null && - duration == null && - trackNumber == null && - totalTracks == null && - discNumber == null && - totalDiscs == null) { - return null; - } - - return { - 'quality': quality, - 'bitDepth': bitDepth, - 'sampleRate': sampleRate, - 'bitrate': bitrateKbps, - 'format': detectedFormat, - 'bitrateKbps': bitrateKbps, - 'composer': composer, - 'duration': duration, - 'trackNumber': trackNumber, - 'totalTracks': totalTracks, - 'discNumber': discNumber, - 'totalDiscs': totalDiscs, - }; - } catch (e) { - _historyLog.d('Audio metadata probe failed for $filePath: $e'); - return null; - } - } - - Future _backfillAudioMetadata( - List items, { - required int maxItems, - required SharedPreferences prefs, - }) async { - if (_isAudioMetadataBackfillInProgress || items.isEmpty) { - return; - } - _isAudioMetadataBackfillInProgress = true; - - try { - final probeFailedPaths = _readAudioProbeFailedPaths(prefs); - final candidateIndexes = []; - for (var i = 0; i < items.length; i++) { - if (!_shouldBackfillAudioMetadata(items[i])) continue; - if (probeFailedPaths.contains(items[i].filePath.trim())) continue; - candidateIndexes.add(i); - } - - if (candidateIndexes.isEmpty) { - await prefs.remove(_startupAudioCursorKey); - return; - } - - final startCursor = _readStartupCursor( - prefs, - _startupAudioCursorKey, - candidateIndexes.length, - ); - final endCursor = (startCursor + maxItems).clamp( - 0, - candidateIndexes.length, - ); - final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor); - - if (selectedIndexes.isEmpty) { - await prefs.remove(_startupAudioCursorKey); - return; - } - - List? updatedItems; - final persistedUpdates = >[]; - var refreshedCount = 0; - - for (final index in selectedIndexes) { - final item = items[index]; - - final probed = await _probeAudioMetadata( - item.filePath, - fallbackQuality: item.quality, - ); - if (probed == null) { - continue; - } - if (probed['permanent_failure'] == true) { - // Remember the path so this file stops being reselected on every - // launch; the content can never parse, only a re-download fixes it. - await _rememberAudioProbeFailure(prefs, item.filePath); - continue; - } - - final resolvedQuality = normalizeOptionalString( - probed['quality'] as String?, - ); - final resolvedBitDepth = probed['bitDepth'] as int?; - final resolvedSampleRate = probed['sampleRate'] as int?; - final resolvedBitrate = probed['bitrate'] as int?; - final resolvedFormat = normalizeOptionalString( - probed['format'] as String?, - ); - final resolvedComposer = normalizeOptionalString( - probed['composer'] as String?, - ); - final resolvedDuration = probed['duration'] as int?; - final resolvedTrackNumber = probed['trackNumber'] as int?; - final resolvedTotalTracks = probed['totalTracks'] as int?; - final resolvedDiscNumber = probed['discNumber'] as int?; - final resolvedTotalDiscs = probed['totalDiscs'] as int?; - - final qualityChanged = - resolvedQuality != null && resolvedQuality != item.quality; - final bitDepthChanged = - resolvedBitDepth != null && resolvedBitDepth != item.bitDepth; - final sampleRateChanged = - resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; - final bitrateChanged = - resolvedBitrate != null && resolvedBitrate != item.bitrate; - final formatChanged = - resolvedFormat != null && resolvedFormat != item.format; - final composerChanged = - resolvedComposer != null && resolvedComposer != item.composer; - final durationChanged = - resolvedDuration != null && resolvedDuration != item.duration; - final trackNumberChanged = - resolvedTrackNumber != null && - resolvedTrackNumber != item.trackNumber; - final totalTracksChanged = - resolvedTotalTracks != null && - resolvedTotalTracks != item.totalTracks; - final discNumberChanged = - resolvedDiscNumber != null && resolvedDiscNumber != item.discNumber; - final totalDiscsChanged = - resolvedTotalDiscs != null && resolvedTotalDiscs != item.totalDiscs; - - if (!qualityChanged && - !bitDepthChanged && - !sampleRateChanged && - !bitrateChanged && - !formatChanged && - !composerChanged && - !durationChanged && - !trackNumberChanged && - !totalTracksChanged && - !discNumberChanged && - !totalDiscsChanged) { - continue; - } - - final updated = item.copyWith( - quality: resolvedQuality, - bitDepth: resolvedBitDepth, - sampleRate: resolvedSampleRate, - bitrate: resolvedBitrate, - format: resolvedFormat, - composer: resolvedComposer, - duration: resolvedDuration, - trackNumber: resolvedTrackNumber, - totalTracks: resolvedTotalTracks, - discNumber: resolvedDiscNumber, - totalDiscs: resolvedTotalDiscs, - ); - updatedItems ??= [...items]; - updatedItems[index] = updated; - persistedUpdates.add(updated.toJson()); - refreshedCount++; - } - - if (persistedUpdates.isNotEmpty && updatedItems != null) { - await _db.upsertBatch(persistedUpdates); - state = state.copyWith( - items: updatedItems, - loadedIndexVersion: state.loadedIndexVersion + 1, - lookupItems: _lookupItemsWithUpdates(updatedItems), - ); - } - - await _writeStartupCursor( - prefs, - _startupAudioCursorKey, - endCursor, - candidateIndexes.length, - ); - - if (refreshedCount > 0) { - _historyLog.i( - 'Audio metadata backfill refreshed $refreshedCount items', - ); - } - } finally { - _isAudioMetadataBackfillInProgress = false; - } - } - Future reloadFromStorage() async { await _loadFromDatabase(); } diff --git a/lib/providers/download_history_provider_maintenance.dart b/lib/providers/download_history_provider_maintenance.dart new file mode 100644 index 00000000..442f6d3f --- /dev/null +++ b/lib/providers/download_history_provider_maintenance.dart @@ -0,0 +1,614 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'download_history_provider.dart'; + +/// Startup maintenance: SAF repair, orphan cleanup, and audio-metadata +/// backfill that run staggered after the initial history load. +extension _HistoryStartupMaintenance on DownloadHistoryNotifier { + void _scheduleStartupMaintenance(List initialItems) { + if (_startupMaintenanceScheduled) { + return; + } + _startupMaintenanceScheduled = true; + + unawaited( + Future.delayed( + DownloadHistoryNotifier._startupMaintenanceDelay, + () async { + try { + final prefs = await SharedPreferences.getInstance(); + + if (Platform.isAndroid) { + await _repairMissingSafEntries( + initialItems, + maxItems: DownloadHistoryNotifier._safRepairMaxPerLaunch, + prefs: prefs, + ); + await Future.delayed( + DownloadHistoryNotifier._startupMaintenanceStepGap, + ); + } + + await _cleanupOrphanedDownloadsIncremental( + maxItems: DownloadHistoryNotifier._orphanCleanupMaxPerLaunch, + prefs: prefs, + ); + await Future.delayed( + DownloadHistoryNotifier._startupMaintenanceStepGap, + ); + + final currentItems = state.items; + if (currentItems.isNotEmpty) { + await _backfillAudioMetadata( + currentItems, + maxItems: + DownloadHistoryNotifier._audioMetadataBackfillMaxPerLaunch, + prefs: prefs, + ); + } + } catch (e, stack) { + _historyLog.w('Startup history maintenance failed: $e'); + _historyLog.d('$stack'); + } + }, + ), + ); + } + + int _readStartupCursor(SharedPreferences prefs, String key, int totalCount) { + if (totalCount <= 0) { + return 0; + } + final cursor = prefs.getInt(key) ?? 0; + if (cursor < 0 || cursor >= totalCount) { + return 0; + } + return cursor; + } + + Future _writeStartupCursor( + SharedPreferences prefs, + String key, + int nextCursor, + int totalCount, + ) async { + if (totalCount <= 0 || nextCursor <= 0 || nextCursor >= totalCount) { + await prefs.remove(key); + return; + } + await prefs.setInt(key, nextCursor); + } + + String _fileNameFromUri(String uri) { + try { + final parsed = Uri.parse(uri); + if (parsed.pathSegments.isNotEmpty) { + return Uri.decodeComponent(parsed.pathSegments.last); + } + } catch (_) {} + return ''; + } + + List _conversionRenameCandidates( + String fileName, { + bool includeAlternateExtensions = false, + }) { + if (fileName.trim().isEmpty) return const []; + final dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0) return [fileName]; + final baseName = fileName.substring(0, dotIndex); + final extension = fileName.substring(dotIndex); + final plainBase = baseName.endsWith('_converted') + ? baseName.substring(0, baseName.length - '_converted'.length) + : baseName; + return { + fileName, + if (plainBase != baseName) '$plainBase$extension', + if (plainBase == baseName) '${baseName}_converted$extension', + if (includeAlternateExtensions) + for (final audioExtension in DownloadHistoryNotifier._audioExtensions) + '$plainBase$audioExtension', + }.toList(growable: false); + } + + Future _repairMissingSafEntries( + List items, { + required int maxItems, + required SharedPreferences prefs, + }) async { + if (_isSafRepairInProgress || items.isEmpty) { + return; + } + _isSafRepairInProgress = true; + + final candidateIndexes = []; + for (var i = 0; i < items.length; i++) { + final item = items[i]; + if (item.storageMode != 'saf') continue; + if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) { + continue; + } + final hasFilePath = item.filePath.trim().isNotEmpty; + final hasSafFileName = + item.safFileName != null && item.safFileName!.trim().isNotEmpty; + if (!hasFilePath && !hasSafFileName) { + continue; + } + candidateIndexes.add(i); + } + + if (candidateIndexes.isEmpty) { + await prefs.remove(DownloadHistoryNotifier._startupSafRepairCursorKey); + _isSafRepairInProgress = false; + return; + } + + final startCursor = _readStartupCursor( + prefs, + DownloadHistoryNotifier._startupSafRepairCursorKey, + candidateIndexes.length, + ); + final endCursor = (startCursor + maxItems).clamp( + 0, + candidateIndexes.length, + ); + final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor); + + if (selectedIndexes.isEmpty) { + await prefs.remove(DownloadHistoryNotifier._startupSafRepairCursorKey); + _isSafRepairInProgress = false; + return; + } + + final updatedItems = [...items]; + final persistedUpdates = >[]; + var changed = false; + var repairedCount = 0; + var verifiedCount = 0; + + try { + for (var c = 0; c < selectedIndexes.length; c++) { + final i = selectedIndexes[c]; + final item = items[i]; + final rawPath = item.filePath.trim(); + final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath); + + if (isDirectSafUri) { + final exists = await fileExists(rawPath); + if (exists) { + final verified = item.copyWith( + safRepaired: true, + safFileName: item.safFileName ?? _fileNameFromUri(rawPath), + ); + updatedItems[i] = verified; + changed = true; + verifiedCount++; + persistedUpdates.add(verified.toJson()); + continue; + } + } + + var fallbackName = (item.safFileName ?? '').trim(); + if (fallbackName.isEmpty && isDirectSafUri) { + fallbackName = _fileNameFromUri(rawPath); + } + if (fallbackName.isEmpty) { + _historyLog.w('Missing SAF filename for history item: ${item.id}'); + continue; + } + + try { + Map? resolved; + String? resolvedFileName; + for (final candidate in _conversionRenameCandidates(fallbackName)) { + final candidateResult = await PlatformBridge.resolveSafFile( + treeUri: item.downloadTreeUri!, + relativeDir: item.safRelativeDir ?? '', + fileName: candidate, + ); + final candidateUri = (candidateResult['uri'] as String? ?? '') + .trim(); + if (candidateUri.isEmpty) continue; + resolved = candidateResult; + resolvedFileName = candidate; + break; + } + if (resolved == null || resolvedFileName == null) continue; + final newUri = (resolved['uri'] as String).trim(); + + final newRelativeDir = resolved['relative_dir'] as String?; + final updated = item.copyWith( + filePath: newUri, + safRelativeDir: + (newRelativeDir != null && newRelativeDir.isNotEmpty) + ? newRelativeDir + : item.safRelativeDir, + safFileName: resolvedFileName, + safRepaired: true, + ); + + updatedItems[i] = updated; + changed = true; + repairedCount++; + persistedUpdates.add(updated.toJson()); + } catch (e) { + _historyLog.w('Failed to repair SAF URI: $e'); + } + + if ((c + 1) % DownloadHistoryNotifier._safRepairBatchSize == 0) { + await Future.delayed(const Duration(milliseconds: 16)); + } + } + + if (changed) { + await _db.upsertBatch(persistedUpdates); + state = state.copyWith( + items: updatedItems, + loadedIndexVersion: state.loadedIndexVersion + 1, + lookupItems: _lookupItemsWithUpdates(updatedItems), + ); + _historyLog.i( + 'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}', + ); + } + await _writeStartupCursor( + prefs, + DownloadHistoryNotifier._startupSafRepairCursorKey, + endCursor, + candidateIndexes.length, + ); + } finally { + _isSafRepairInProgress = false; + } + } + + bool _supportsAudioMetadataProbe(String filePath) { + final trimmed = filePath.trim().toLowerCase(); + if (trimmed.isEmpty) return false; + if (trimmed.startsWith('content://')) return true; + return trimmed.endsWith('.flac') || + trimmed.endsWith('.m4a') || + trimmed.endsWith('.mp4') || + trimmed.endsWith('.aac') || + trimmed.endsWith('.mp3') || + trimmed.endsWith('.opus') || + trimmed.endsWith('.ogg'); + } + + bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) { + if (!_supportsAudioMetadataProbe(item.filePath)) { + return false; + } + + final trimmedPath = item.filePath.trim().toLowerCase(); + final hasResolvedSpecs = + item.bitDepth != null && + item.bitDepth! > 0 && + item.sampleRate != null && + item.sampleRate! > 0; + final needsFormatBackfill = normalizeOptionalString(item.format) == null; + final needsLosslessSpecProbe = + !hasResolvedSpecs && + (trimmedPath.endsWith('.flac') || + trimmedPath.endsWith('.m4a') || + trimmedPath.endsWith('.mp4') || + trimmedPath.endsWith('.aac') || + trimmedPath.startsWith('content://')); + + if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) { + final needsComposerBackfill = + normalizeOptionalString(item.composer) == null; + final needsDurationBackfill = item.duration == null || item.duration == 0; + final needsTrackNumberBackfill = item.trackNumber == null; + final needsTotalTracksBackfill = item.totalTracks == null; + final needsDiscNumberBackfill = item.discNumber == null; + final needsTotalDiscsBackfill = item.totalDiscs == null; + return needsComposerBackfill || + needsFormatBackfill || + needsDurationBackfill || + needsTrackNumberBackfill || + needsTotalTracksBackfill || + needsDiscNumberBackfill || + needsTotalDiscsBackfill; + } + + final needsComposerBackfill = + normalizeOptionalString(item.composer) == null; + final needsDurationBackfill = item.duration == null || item.duration == 0; + final needsTrackNumberBackfill = item.trackNumber == null; + final needsTotalTracksBackfill = item.totalTracks == null; + final needsDiscNumberBackfill = item.discNumber == null; + final needsTotalDiscsBackfill = item.totalDiscs == null; + return needsLosslessSpecProbe || + needsFormatBackfill || + isPlaceholderQualityLabel(item.quality) || + normalizeOptionalString(item.quality) == null || + needsComposerBackfill || + needsDurationBackfill || + needsTrackNumberBackfill || + needsTotalTracksBackfill || + needsDiscNumberBackfill || + needsTotalDiscsBackfill; + } + + /// Errors that indicate the file content itself cannot be parsed — as + /// opposed to transient conditions like a missing file or an unmounted + /// SAF volume. These never resolve on retry. + static bool _isPermanentProbeError(String error) { + final lower = error.toLowerCase(); + return lower.contains('failed to parse') || + lower.contains('head incorrect') || + lower.contains('invalid') || + lower.contains('not a '); + } + + Set _readAudioProbeFailedPaths(SharedPreferences prefs) { + final stored = prefs.getStringList( + DownloadHistoryNotifier._audioProbeFailedPathsKey, + ); + if (stored == null || stored.isEmpty) return {}; + return stored.toSet(); + } + + Future _rememberAudioProbeFailure( + SharedPreferences prefs, + String filePath, + ) async { + final normalized = filePath.trim(); + if (normalized.isEmpty) return; + final stored = + prefs.getStringList( + DownloadHistoryNotifier._audioProbeFailedPathsKey, + ) ?? + []; + if (stored.contains(normalized)) return; + stored.add(normalized); + while (stored.length > DownloadHistoryNotifier._audioProbeFailedPathsMax) { + stored.removeAt(0); + } + await prefs.setStringList( + DownloadHistoryNotifier._audioProbeFailedPathsKey, + stored, + ); + } + + Future?> _probeAudioMetadata( + String filePath, { + String? fallbackQuality, + }) async { + if (!_supportsAudioMetadataProbe(filePath)) { + return null; + } + + try { + final result = await PlatformBridge.readFileMetadata(filePath); + final error = result['error']; + if (error != null) { + if (_isPermanentProbeError(error.toString())) { + // The file content itself is unparseable (e.g. an MP4 stream under + // a .flac name); retrying on every launch can never succeed. + return const {'permanent_failure': true}; + } + return null; + } + + final bitDepth = readPositiveInt(result['bit_depth']); + final sampleRate = readPositiveInt(result['sample_rate']); + final detectedFormat = normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? result['format']?.toString(), + ); + final rawBitrateKbps = readPositiveBitrateKbps(result['bitrate']); + final bitrateKbps = isLossyAudioFormat(detectedFormat) + ? rawBitrateKbps + : null; + final quality = resolveDisplayQuality( + filePath: filePath, + detectedFormat: detectedFormat, + bitDepth: bitDepth, + sampleRate: sampleRate, + bitrateKbps: bitrateKbps, + storedQuality: fallbackQuality, + ); + final composer = normalizeOptionalString(result['composer']?.toString()); + final duration = readPositiveInt(result['duration']); + final trackNumber = readPositiveInt(result['track_number']); + final totalTracks = readPositiveInt(result['total_tracks']); + final discNumber = readPositiveInt(result['disc_number']); + final totalDiscs = readPositiveInt(result['total_discs']); + + if (quality == null && + bitDepth == null && + sampleRate == null && + bitrateKbps == null && + detectedFormat == null && + composer == null && + duration == null && + trackNumber == null && + totalTracks == null && + discNumber == null && + totalDiscs == null) { + return null; + } + + return { + 'quality': quality, + 'bitDepth': bitDepth, + 'sampleRate': sampleRate, + 'bitrate': bitrateKbps, + 'format': detectedFormat, + 'bitrateKbps': bitrateKbps, + 'composer': composer, + 'duration': duration, + 'trackNumber': trackNumber, + 'totalTracks': totalTracks, + 'discNumber': discNumber, + 'totalDiscs': totalDiscs, + }; + } catch (e) { + _historyLog.d('Audio metadata probe failed for $filePath: $e'); + return null; + } + } + + Future _backfillAudioMetadata( + List items, { + required int maxItems, + required SharedPreferences prefs, + }) async { + if (_isAudioMetadataBackfillInProgress || items.isEmpty) { + return; + } + _isAudioMetadataBackfillInProgress = true; + + try { + final probeFailedPaths = _readAudioProbeFailedPaths(prefs); + final candidateIndexes = []; + for (var i = 0; i < items.length; i++) { + if (!_shouldBackfillAudioMetadata(items[i])) continue; + if (probeFailedPaths.contains(items[i].filePath.trim())) continue; + candidateIndexes.add(i); + } + + if (candidateIndexes.isEmpty) { + await prefs.remove(DownloadHistoryNotifier._startupAudioCursorKey); + return; + } + + final startCursor = _readStartupCursor( + prefs, + DownloadHistoryNotifier._startupAudioCursorKey, + candidateIndexes.length, + ); + final endCursor = (startCursor + maxItems).clamp( + 0, + candidateIndexes.length, + ); + final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor); + + if (selectedIndexes.isEmpty) { + await prefs.remove(DownloadHistoryNotifier._startupAudioCursorKey); + return; + } + + List? updatedItems; + final persistedUpdates = >[]; + var refreshedCount = 0; + + for (final index in selectedIndexes) { + final item = items[index]; + + final probed = await _probeAudioMetadata( + item.filePath, + fallbackQuality: item.quality, + ); + if (probed == null) { + continue; + } + if (probed['permanent_failure'] == true) { + // Remember the path so this file stops being reselected on every + // launch; the content can never parse, only a re-download fixes it. + await _rememberAudioProbeFailure(prefs, item.filePath); + continue; + } + + final resolvedQuality = normalizeOptionalString( + probed['quality'] as String?, + ); + final resolvedBitDepth = probed['bitDepth'] as int?; + final resolvedSampleRate = probed['sampleRate'] as int?; + final resolvedBitrate = probed['bitrate'] as int?; + final resolvedFormat = normalizeOptionalString( + probed['format'] as String?, + ); + final resolvedComposer = normalizeOptionalString( + probed['composer'] as String?, + ); + final resolvedDuration = probed['duration'] as int?; + final resolvedTrackNumber = probed['trackNumber'] as int?; + final resolvedTotalTracks = probed['totalTracks'] as int?; + final resolvedDiscNumber = probed['discNumber'] as int?; + final resolvedTotalDiscs = probed['totalDiscs'] as int?; + + final qualityChanged = + resolvedQuality != null && resolvedQuality != item.quality; + final bitDepthChanged = + resolvedBitDepth != null && resolvedBitDepth != item.bitDepth; + final sampleRateChanged = + resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; + final bitrateChanged = + resolvedBitrate != null && resolvedBitrate != item.bitrate; + final formatChanged = + resolvedFormat != null && resolvedFormat != item.format; + final composerChanged = + resolvedComposer != null && resolvedComposer != item.composer; + final durationChanged = + resolvedDuration != null && resolvedDuration != item.duration; + final trackNumberChanged = + resolvedTrackNumber != null && + resolvedTrackNumber != item.trackNumber; + final totalTracksChanged = + resolvedTotalTracks != null && + resolvedTotalTracks != item.totalTracks; + final discNumberChanged = + resolvedDiscNumber != null && resolvedDiscNumber != item.discNumber; + final totalDiscsChanged = + resolvedTotalDiscs != null && resolvedTotalDiscs != item.totalDiscs; + + if (!qualityChanged && + !bitDepthChanged && + !sampleRateChanged && + !bitrateChanged && + !formatChanged && + !composerChanged && + !durationChanged && + !trackNumberChanged && + !totalTracksChanged && + !discNumberChanged && + !totalDiscsChanged) { + continue; + } + + final updated = item.copyWith( + quality: resolvedQuality, + bitDepth: resolvedBitDepth, + sampleRate: resolvedSampleRate, + bitrate: resolvedBitrate, + format: resolvedFormat, + composer: resolvedComposer, + duration: resolvedDuration, + trackNumber: resolvedTrackNumber, + totalTracks: resolvedTotalTracks, + discNumber: resolvedDiscNumber, + totalDiscs: resolvedTotalDiscs, + ); + updatedItems ??= [...items]; + updatedItems[index] = updated; + persistedUpdates.add(updated.toJson()); + refreshedCount++; + } + + if (persistedUpdates.isNotEmpty && updatedItems != null) { + await _db.upsertBatch(persistedUpdates); + state = state.copyWith( + items: updatedItems, + loadedIndexVersion: state.loadedIndexVersion + 1, + lookupItems: _lookupItemsWithUpdates(updatedItems), + ); + } + + await _writeStartupCursor( + prefs, + DownloadHistoryNotifier._startupAudioCursorKey, + endCursor, + candidateIndexes.length, + ); + + if (refreshedCount > 0) { + _historyLog.i( + 'Audio metadata backfill refreshed $refreshedCount items', + ); + } + } finally { + _isAudioMetadataBackfillInProgress = false; + } + } +}