fix(saf): use extension-agnostic .partial staged filename

Staged SAF outputs and library-scan partials now share a single naming pattern: '<name>.partial' regardless of the audio extension. The previous '<name>.partial.<ext>' 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 '<name>.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.
This commit is contained in:
zarzet 2026-05-08 20:35:41 +07:00
parent fb5d8826a2
commit 1bd54c530b
3 changed files with 59 additions and 11 deletions

View file

@ -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("/", " ")

View file

@ -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] {

View file

@ -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 {