From a8a39732250692994e11cfb446b14b4ab5a8b39d Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 16 Mar 2026 20:28:53 +0700 Subject: [PATCH] fix: prevent re-download of tracks converted to a different format When a file is converted externally (e.g. FLAC to OPUS), the orphan cleanup would delete the history entry because the original path no longer exists. Now it checks for sibling files with other audio extensions and updates the stored path instead of deleting. Also add extension-stripped keys to path_match_keys so that paths differing only by audio extension still match during local library scan exclusion and queue deduplication. --- lib/providers/download_queue_provider.dart | 47 +++++++++++++++++++++- lib/screens/settings/donate_page.dart | 4 +- lib/utils/path_match_keys.dart | 39 ++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a01b8e88..6f983bf4 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -770,6 +770,37 @@ class DownloadHistoryNotifier extends Notifier { /// Remove history entries where the file no longer exists on disk /// Returns the number of orphaned entries removed + /// Audio file extensions that the app commonly produces or converts between. + static const _audioExtensions = [ + '.flac', + '.m4a', + '.mp3', + '.opus', + '.ogg', + '.wav', + '.aac', + ]; + + /// When the original file is missing, check whether a sibling with a + /// different audio extension exists (e.g. the user converted .flac → .opus). + /// Returns the path of the first match found, or `null` if none exist. + Future _findConvertedSibling(String originalPath) async { + // Strip the current extension to get the base path. + final dotIndex = originalPath.lastIndexOf('.'); + if (dotIndex < 0) return null; + final basePath = originalPath.substring(0, dotIndex); + final originalExt = originalPath.substring(dotIndex).toLowerCase(); + + for (final ext in _audioExtensions) { + if (ext == originalExt) continue; + final candidatePath = '$basePath$ext'; + try { + if (await fileExists(candidatePath)) return candidatePath; + } catch (_) {} + } + return null; + } + Future cleanupOrphanedDownloads() async { _historyLog.i('Starting orphaned downloads cleanup...'); @@ -791,7 +822,21 @@ class DownloadHistoryNotifier extends Notifier { if (filePath == null || filePath.isEmpty) return null; pathById[id] = filePath; try { - return MapEntry(id, await fileExists(filePath)); + if (await fileExists(filePath)) return MapEntry(id, true); + + // Original file missing -- check for a converted sibling. + final sibling = await _findConvertedSibling(filePath); + if (sibling != null) { + _historyLog.i( + 'Found converted sibling for $id: $filePath → $sibling', + ); + // Update the stored path so future checks succeed immediately. + await _db.updateFilePath(id, sibling); + pathById[id] = sibling; + return MapEntry(id, true); + } + + return MapEntry(id, false); } catch (e) { _historyLog.w('Error checking file existence for $id: $e'); return MapEntry(id, false); diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index e1f76a6d..b810f003 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -164,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - const donorNames = ['a fan', 'mc nuggets jimmy', 'CJBGR', 'michahRicie']; + const donorNames = ['micahRichie', 'a fan', 'mc nuggets jimmy', 'CJBGR']; // Match SettingsGroup color logic final cardColor = isDark @@ -480,7 +480,7 @@ int _cr(String v) { } // Highlighted supporters (hashes of names). -const _cv = {1365043105}; +const _cv = {1211573191}; class _SupporterChip extends StatelessWidget { final String name; diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart index ace140dd..0df1c023 100644 --- a/lib/utils/path_match_keys.dart +++ b/lib/utils/path_match_keys.dart @@ -8,6 +8,33 @@ const _androidStoragePathAliases = [ '/mnt/sdcard', ]; +/// Audio file extensions that the app commonly produces or converts between. +/// Used to generate extension-stripped match keys so that a file converted from +/// one format to another (e.g. .flac → .opus) is still recognised as the same +/// track. +const _audioExtensions = [ + '.flac', + '.m4a', + '.mp3', + '.opus', + '.ogg', + '.wav', + '.aac', +]; + +/// Strips a trailing audio extension from [path] if present. +/// Returns the path without extension, or `null` if no known audio extension +/// was found. +String? _stripAudioExtension(String path) { + final lower = path.toLowerCase(); + for (final ext in _audioExtensions) { + if (lower.endsWith(ext)) { + return path.substring(0, path.length - ext.length); + } + } + return null; +} + Set buildPathMatchKeys(String? filePath) { final raw = filePath?.trim() ?? ''; if (raw.isEmpty) return const {}; @@ -79,6 +106,18 @@ Set buildPathMatchKeys(String? filePath) { } addNormalized(cleaned); + + // Add extension-stripped variants so that a file converted from one audio + // format to another (e.g. Song.flac → Song.opus) still matches. + final extensionStrippedKeys = {}; + for (final key in keys) { + final stripped = _stripAudioExtension(key); + if (stripped != null && stripped.isNotEmpty) { + extensionStrippedKeys.add(stripped); + } + } + keys.addAll(extensionStrippedKeys); + return keys; }