mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 05:51:18 +02:00
fix(metadata): stop the endless FLAC-write failures on misnamed files
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
This commit is contained in:
@@ -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://")) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -308,6 +308,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
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<String> _readAudioProbeFailedPaths(SharedPreferences prefs) {
|
||||
final stored = prefs.getStringList(_audioProbeFailedPathsKey);
|
||||
if (stored == null || stored.isEmpty) return <String>{};
|
||||
return stored.toSet();
|
||||
}
|
||||
|
||||
Future<void> _rememberAudioProbeFailure(
|
||||
SharedPreferences prefs,
|
||||
String filePath,
|
||||
) async {
|
||||
final normalized = filePath.trim();
|
||||
if (normalized.isEmpty) return;
|
||||
final stored =
|
||||
prefs.getStringList(_audioProbeFailedPathsKey) ?? <String>[];
|
||||
if (stored.contains(normalized)) return;
|
||||
stored.add(normalized);
|
||||
while (stored.length > _audioProbeFailedPathsMax) {
|
||||
stored.removeAt(0);
|
||||
}
|
||||
await prefs.setStringList(_audioProbeFailedPathsKey, stored);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _probeAudioMetadata(
|
||||
String filePath, {
|
||||
String? fallbackQuality,
|
||||
@@ -661,7 +697,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
|
||||
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<DownloadHistoryState> {
|
||||
_isAudioMetadataBackfillInProgress = true;
|
||||
|
||||
try {
|
||||
final probeFailedPaths = _readAudioProbeFailedPaths(prefs);
|
||||
final candidateIndexes = <int>[];
|
||||
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<DownloadHistoryState> {
|
||||
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?,
|
||||
|
||||
Reference in New Issue
Block a user