From 1bd54c530b34c3f299c2d7375a83409e850baaff Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 8 May 2026 20:35:41 +0700 Subject: [PATCH] fix(saf): use extension-agnostic .partial staged filename Staged SAF outputs and library-scan partials now share a single naming pattern: '.partial' regardless of the audio extension. The previous '.partial.' form caused SAF / media-scanner to surface half-written files as valid audio. - SafDownloadHandler: force 'application/octet-stream' MIME for staged docs and collapse buildStagedSafFileName to '.partial'. Keep the legacy form behind buildLegacyStagedSafFileName and sweep both via deleteStaleStagedFiles so upgrades clean old residue. - library_scan: add isLibraryStagingFile that skips both the new and legacy partial patterns during collectLibraryAudioFiles so residual staging files never show up in the library. - library_scan_supplement_test: seed both legacy and new partial files and assert they are ignored by the scanner. --- .../com/zarz/spotiflac/SafDownloadHandler.kt | 41 ++++++++++++++----- go_backend/library_scan.go | 16 ++++++++ go_backend/library_scan_supplement_test.go | 13 ++++++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt index 13cb9397..40f6a986 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -15,6 +15,7 @@ import java.util.Locale object SafDownloadHandler { private val safDirLock = Any() private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180 + private const val STAGED_SAF_MIME_TYPE = "application/octet-stream" fun handle(context: Context, requestJson: String, downloader: (String) -> String): String { val req = JSONObject(requestJson) @@ -31,15 +32,15 @@ object SafDownloadHandler { val fileName = buildSafFileName(req, outputExt) val deferSafPublish = req.optBoolean("defer_saf_publish", false) val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish - val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName - val staleStagedFileName = buildStagedSafFileName(fileName, outputExt) + val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName + val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType val existingDir = findDocumentDir(context, treeUri, relativeDir) if (existingDir != null) { val existing = existingDir.findFile(fileName) if (existing != null && existing.isFile && existing.length() > 0) { if (useStagedOutput || deferSafPublish) { - existingDir.findFile(staleStagedFileName)?.delete() + deleteStaleStagedFiles(existingDir, fileName, outputExt) } val obj = JSONObject() obj.put("success", true) @@ -55,7 +56,7 @@ object SafDownloadHandler { ?: return errorJson("Failed to access SAF directory") if (deferSafPublish) { - targetDir.findFile(staleStagedFileName)?.delete() + deleteStaleStagedFiles(targetDir, fileName, outputExt) val workingExt = outputExt.ifBlank { ".tmp" } val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir) Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}") @@ -89,7 +90,7 @@ object SafDownloadHandler { } } - var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName) + var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName) ?: return errorJson("Failed to create SAF file") val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw") @@ -121,14 +122,14 @@ object SafDownloadHandler { if (actualExt.isNotBlank() && actualExt != outputExt) { val actualFileName = buildSafFileName(req, actualExt) val actualStagedFileName = if (useStagedOutput) { - buildStagedSafFileName(actualFileName, actualExt) + buildStagedSafFileName(actualFileName) } else { actualFileName } val actualMimeType = mimeTypeForExt(actualExt) val replacement = createOrReuseDocumentFile( targetDir, - actualMimeType, + if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType, actualStagedFileName ) ?: throw IllegalStateException( "failed to create SAF output with actual extension" @@ -212,8 +213,9 @@ object SafDownloadHandler { val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null val finalName = sanitizeFilename(fileName) val ext = normalizeExt(finalName.substringAfterLast('.', "")) - val stagedName = buildStagedSafFileName(finalName, ext) - val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName) + val stagedName = buildStagedSafFileName(finalName) + deleteStaleStagedFiles(targetDir, finalName, ext) + val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName) ?: return null stagedDocument = document val outputStream = context.contentResolver.openOutputStream(document.uri, "wt") @@ -288,13 +290,17 @@ object SafDownloadHandler { return safeName + normalizedExt } - private fun buildStagedSafFileName(fileName: String, outputExt: String): String { + private fun buildStagedSafFileName(fileName: String): String { + val safeName = sanitizeFilename(fileName) + return "$safeName.partial" + } + + private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String { val safeName = sanitizeFilename(fileName) val ext = normalizeExt(outputExt) if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) { return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext" } - val dot = safeName.lastIndexOf('.') if (dot > 0 && dot < safeName.lastIndex) { return safeName.substring(0, dot).trimEnd('.', ' ') + @@ -304,6 +310,19 @@ object SafDownloadHandler { return "$safeName.partial" } + private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) { + val stagedNames = linkedSetOf( + buildStagedSafFileName(fileName), + buildLegacyStagedSafFileName(fileName, outputExt) + ) + for (stagedName in stagedNames) { + try { + parent.findFile(stagedName)?.delete() + } catch (_: Exception) { + } + } + } + private fun sanitizeFilename(name: String): String { var sanitized = name .replace("/", " ") diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index ea153256..d15e9a4f 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -87,6 +87,19 @@ type scannedCueFileInfo struct { audioPath string } +func isLibraryStagingFile(path string) bool { + name := strings.ToLower(filepath.Base(path)) + if strings.HasSuffix(name, ".partial") { + return true + } + for ext := range supportedAudioFormats { + if strings.HasSuffix(name, ".partial"+ext) { + return true + } + } + return false +} + func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) { var files []libraryAudioFileInfo @@ -104,6 +117,9 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li if entry.IsDir() { return nil } + if isLibraryStagingFile(path) { + return nil + } ext := strings.ToLower(filepath.Ext(path)) if !supportedAudioFormats[ext] { diff --git a/go_backend/library_scan_supplement_test.go b/go_backend/library_scan_supplement_test.go index be23a8ff..04189359 100644 --- a/go_backend/library_scan_supplement_test.go +++ b/go_backend/library_scan_supplement_test.go @@ -42,6 +42,14 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) { if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil { t.Fatal(err) } + legacyPartialPath := filepath.Join(albumDir, "Artist - Song.partial.flac") + if err := os.WriteFile(legacyPartialPath, []byte("partial flac"), 0600); err != nil { + t.Fatal(err) + } + newPartialPath := filepath.Join(albumDir, "Artist - Song.flac.partial") + if err := os.WriteFile(newPartialPath, []byte("partial flac"), 0600); err != nil { + t.Fatal(err) + } files, err := collectLibraryAudioFiles(dir, make(chan struct{})) if err != nil { @@ -50,6 +58,11 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) { if len(files) < 4 { t.Fatalf("files = %#v", files) } + for _, file := range files { + if file.path == legacyPartialPath || file.path == newPartialPath { + t.Fatalf("staging file should be ignored: %#v", files) + } + } cancelCh := make(chan struct{}) close(cancelCh) if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {