diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a01b8e8..6f983bf 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/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart index ace140d..0df1c02 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; }