From 364b2aa1389e4b04dd6ab69bdfda48e713a4f5fb Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 10 Jul 2026 12:07:11 +0700 Subject: [PATCH] fix(metadata): stop the endless FLAC-write failures on misnamed files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider delivers a lossy/unknown stream, the native finalizer preserved the container but kept the requested .flac name, so every metadata/ReplayGain write treated the file as FLAC and failed with "failed to write FLAC metadata: failed to parse FLAC file: fLaC head incorrect" — on the first download, on every retry (the file is never deleted from public storage), on every manual edit attempt, and the history backfill re-probed the same file on every app launch forever. - Finalizer now renames a preserved lossy/unknown container away from its .flac name to match the real content (aac/MP4 -> .m4a, mp3, opus), mirroring the Dart pipeline, so the correct tag writer is picked - EditFileMetadata sniffs MP4 content before taking the .flac branch and reports what is actually wrong (rename to .m4a) instead of the cryptic parse error - The history audio-metadata backfill remembers paths whose probe failed permanently (unparseable content) and stops reselecting them on every launch; transient failures (missing file, unmounted volume) still retry --- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 63 +++++++++++++++++++ go_backend/exports_metadata.go | 9 +++ lib/providers/download_history_provider.dart | 57 +++++++++++++++-- 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 03335a2f..5b8fb098 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -584,6 +584,12 @@ object NativeDownloadFinalizer { val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput) if (!isLosslessAudioCodec(codec)) { Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}") + // The preserved stream is not FLAC but still carries the + // requested .flac name. Rename to the real container so the + // metadata/ReplayGain writers pick the right format — an + // MP4 stream under a .flac name fails "fLaC head incorrect" + // on every subsequent write and the file is never repaired. + adoptPreservedContainerExtension(state, localInput, codec) return } if (isAlreadyNativeFlac) { @@ -614,6 +620,63 @@ object NativeDownloadFinalizer { } } + /// Renames a preserved lossy/unknown stream away from its requested .flac + /// name to match its actual container (mirrors the Dart pipeline's + /// post-download rename). Local files only: legacy content:// outputs are + /// left untouched. No-op when the container cannot be identified. + private fun adoptPreservedContainerExtension( + state: FinalizeState, + localInput: String, + codec: String, + ) { + if (state.filePath.startsWith("content://")) return + val currentFile = File(state.filePath) + if (!currentFile.exists()) return + if (!currentFile.name.lowercase(Locale.ROOT).endsWith(".flac")) return + + val newExt = when { + codec == "aac" && isMP4ContainerFile(localInput) -> ".m4a" + codec == "mp3" -> ".mp3" + codec == "opus" -> ".opus" + isMP4ContainerFile(localInput) -> ".m4a" + else -> return + } + val renamed = File( + currentFile.parentFile, + currentFile.name.dropLast(".flac".length) + newExt, + ) + if (renamed.exists() && !renamed.delete()) { + Log.w(TAG, "Cannot adopt container extension; ${renamed.name} already exists") + return + } + if (!currentFile.renameTo(renamed)) { + Log.w(TAG, "Failed to rename preserved container to ${renamed.name}") + return + } + Log.i(TAG, "Preserved container renamed: ${currentFile.name} -> ${renamed.name}") + state.filePath = renamed.absolutePath + if (state.fileName.isNotBlank()) { + state.fileName = renamed.name + } + state.audioCodec = normalizeAudioCodec(codec) + } + + private fun isMP4ContainerFile(path: String): Boolean { + return try { + File(path).inputStream().use { stream -> + val header = ByteArray(12) + val read = stream.read(header) + read >= 8 && + header[4] == 'f'.code.toByte() && + header[5] == 't'.code.toByte() && + header[6] == 'y'.code.toByte() && + header[7] == 'p'.code.toByte() + } + } catch (_: Exception) { + false + } + } + private fun finalizeMetadata(context: Context, input: FinalizeInput, state: FinalizeState) { if (!input.request.optBoolean("embed_metadata", false)) return if (!state.filePath.startsWith("content://")) { diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index 5f2f278b..0bfcc739 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -438,6 +438,15 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { } if isFlac { + // A .flac name does not guarantee FLAC content: providers sometimes + // deliver an MP4/M4A stream that ends up under the requested name. + // The FLAC writer would fail "fLaC head incorrect" on every attempt, + // so detect the mismatch up front and say what is actually wrong. + if isMP4ContainerFile(filePath) { + return "", fmt.Errorf( + "failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a", + ) + } if err := EditFlacFields(filePath, fields); err != nil { return "", fmt.Errorf("failed to write FLAC metadata: %w", err) } diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index d8aa4611..646708b4 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -308,6 +308,9 @@ class DownloadHistoryNotifier extends Notifier { static const _startupOrphanSuspectPrefix = 'history_startup_orphan_suspect_v1_'; static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1'; + static const _audioProbeFailedPathsKey = + 'history_audio_probe_failed_paths_v1'; + static const _audioProbeFailedPathsMax = 300; final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; bool _isSafRepairInProgress = false; @@ -651,6 +654,39 @@ class DownloadHistoryNotifier extends Notifier { 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, @@ -661,7 +697,13 @@ class DownloadHistoryNotifier extends Notifier { try { final result = await PlatformBridge.readFileMetadata(filePath); - if (result['error'] != null) { + 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; } @@ -734,11 +776,12 @@ class DownloadHistoryNotifier extends Notifier { _isAudioMetadataBackfillInProgress = true; try { + final probeFailedPaths = _readAudioProbeFailedPaths(prefs); final candidateIndexes = []; for (var i = 0; i < items.length; i++) { - if (_shouldBackfillAudioMetadata(items[i])) { - candidateIndexes.add(i); - } + if (!_shouldBackfillAudioMetadata(items[i])) continue; + if (probeFailedPaths.contains(items[i].filePath.trim())) continue; + candidateIndexes.add(i); } if (candidateIndexes.isEmpty) { @@ -776,6 +819,12 @@ class DownloadHistoryNotifier extends Notifier { 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?,