From e7fbaf7b75faedacb4fe4072d2eceaa36de449ac Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 29 Jul 2026 02:43:35 +0700 Subject: [PATCH] fix(download): suffix only colliding quality variants --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 32 ++++ .../zarz/spotiflac/NativeDownloadFinalizer.kt | 2 + .../spotiflac/NativeFinalizationPolicy.kt | 30 ++++ .../zarz/spotiflac/NativeFinalizerMedia.kt | 71 ++++++-- .../spotiflac/NativeFinalizerSafPublish.kt | 60 +++++-- .../com/zarz/spotiflac/SafDownloadHandler.kt | 78 +++++++++ .../spotiflac/NativeFinalizationPolicyTest.kt | 32 ++++ go_backend/extension_fallback.go | 17 ++ go_backend/extension_fallback_output.go | 12 ++ go_backend/extension_fallback_output_test.go | 35 ++++ lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/app_localizations_es.dart | 4 +- lib/l10n/app_localizations_fr.dart | 2 +- lib/l10n/app_localizations_id.dart | 2 +- lib/l10n/app_localizations_ja.dart | 2 +- lib/l10n/app_localizations_ko.dart | 2 +- lib/l10n/app_localizations_pt.dart | 4 +- lib/l10n/app_localizations_ru.dart | 2 +- lib/l10n/app_localizations_tr.dart | 2 +- lib/l10n/app_localizations_uk.dart | 2 +- lib/l10n/arb/app_de.arb | 2 +- lib/l10n/arb/app_en.arb | 2 +- lib/l10n/arb/app_es_ES.arb | 2 +- lib/l10n/arb/app_fr.arb | 2 +- lib/l10n/arb/app_id.arb | 2 +- lib/l10n/arb/app_ja.arb | 2 +- lib/l10n/arb/app_ko.arb | 2 +- lib/l10n/arb/app_pt_PT.arb | 2 +- lib/l10n/arb/app_ru.arb | 2 +- lib/l10n/arb/app_tr.arb | 2 +- lib/l10n/arb/app_uk.arb | 2 +- lib/providers/download_queue_provider.dart | 28 ++++ .../download_queue_provider_finalization.dart | 155 +++++++++++++----- ...download_queue_provider_native_worker.dart | 8 + .../download_queue_provider_single_item.dart | 6 + lib/services/download_request_payload.dart | 4 + lib/services/platform_bridge.dart | 43 +++-- lib/utils/audio_format_utils.dart | 38 +++++ test/models_and_utils_test.dart | 38 +++++ 41 files changed, 629 insertions(+), 110 deletions(-) create mode 100644 go_backend/extension_fallback_output_test.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 7c10899c..ba85714b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1052,6 +1052,38 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "safCreateCollisionAwareFromPath" -> { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val cleanFileName = call.argument("clean_file_name") ?: "" + val variantFileName = call.argument("variant_file_name") ?: "" + val mimeType = call.argument("mime_type") ?: "application/octet-stream" + val srcPath = call.argument("src_path") ?: "" + val preservedSuffix = call.argument("preserved_suffix") ?: "" + val response = withContext(Dispatchers.IO) { + if ( + treeUriStr.isBlank() || + cleanFileName.isBlank() || + variantFileName.isBlank() + ) return@withContext null + SafDownloadHandler.writeFileToSafCollisionAware( + context = this@MainActivity, + treeUriStr = treeUriStr, + relativeDir = relativeDir, + cleanFileName = cleanFileName, + variantFileName = variantFileName, + mimeType = mimeType, + srcPath = srcPath, + preservedSuffix = preservedSuffix, + )?.let { writeResult -> + JSONObject() + .put("uri", writeResult.uri) + .put("file_name", writeResult.fileName) + .toString() + } + } + result.success(response) + } "openContentUri" -> { val uriStr = call.argument("uri") ?: "" val mimeType = call.argument("mime_type") ?: "" 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 714b315e..771dadae 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -44,6 +44,7 @@ object NativeDownloadFinalizer { internal val nativeFFmpegSessionIds = mutableSetOf() internal val activeFFmpegSessionLock = Any() internal val ffmpegCompleteCallbackLock = Any() + internal val qualityVariantNameLocks = java.util.concurrent.ConcurrentHashMap() internal var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null internal val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session -> val isNativeSession = synchronized(activeFFmpegSessionLock) { @@ -257,6 +258,7 @@ object NativeDownloadFinalizer { .optBoolean("allow_quality_variant", false) val history = if ( saveDownloadHistory && + !result.optBoolean("publish_collision_existing", false) && !(preserveQualityVariant && result.optBoolean("already_exists", false)) ) { try { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt index 1d754925..86c1b80b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -157,6 +157,36 @@ internal object NativeFinalizationPolicy { return "$stem - $qualityLabel$extension" } + fun removeQualityVariantStagingLabel( + fileName: String, + stagingLabel: String, + ): String { + if (stagingLabel.isEmpty() || !fileName.contains(stagingLabel)) return fileName + val dotIndex = fileName.lastIndexOf('.') + val hasExtension = dotIndex > 0 + val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName + val extension = if (hasExtension) fileName.substring(dotIndex) else "" + val cleanedStem = stem + .replace(stagingLabel, "") + .replace(Regex("[\\s_-]+$"), "") + .trim() + .ifBlank { "track" } + return "$cleanedStem$extension" + } + + fun resolveQualityVariantFilename( + fileName: String, + stagingLabel: String, + qualityLabel: String, + collisionOnly: Boolean, + cleanNameExists: Boolean, + ): String { + if (!collisionOnly || cleanNameExists) { + return applyQualityVariantFilenameLabel(fileName, stagingLabel, qualityLabel) + } + return removeQualityVariantStagingLabel(fileName, stagingLabel) + } + /** * Returns the user-facing name that a deferred SAF download was assigned * before its audio was materialized in the app cache. Container and diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt index 466b91aa..acf4b942 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt @@ -21,6 +21,8 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.logicalOutputFileName +import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel +import com.zarz.spotiflac.NativeFinalizationPolicy.resolveQualityVariantFilename import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension import gobackend.Gobackend @@ -68,11 +70,14 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename( requestSafFileName = input.request.optString("saf_file_name", ""), currentFileName = state.fileName, ) - val preferredName = applyQualityVariantFilenameLabel( + val variantName = applyQualityVariantFilenameLabel( fileName = logicalFileName, stagingLabel = stagingLabel, qualityLabel = qualityLabel, ) + val cleanName = removeQualityVariantStagingLabel(logicalFileName, stagingLabel) + val collisionOnly = input.request.optBoolean("quality_variant_collision_only", false) + val preferredName = variantName if (preferredName == logicalFileName && preferredName == state.fileName) return input.result.put("quality_variant_file_name", preferredName) if (isDeferredSafPublish(input)) { @@ -83,15 +88,30 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename( if (state.filePath.startsWith("content://")) { val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return try { - val writeResult = SafDownloadHandler.writeFileToSafUnique( - context = context, - treeUriStr = input.request.optString("saf_tree_uri", ""), - relativeDir = input.request.optString("saf_relative_dir", ""), - fileName = preferredName, - mimeType = mimeTypeForExt(File(preferredName).extension), - srcPath = tempPath, - preservedSuffix = qualityLabel, - ) ?: return + val treeUri = input.request.optString("saf_tree_uri", "") + val relativeDir = input.request.optString("saf_relative_dir", "") + val writeResult = if (collisionOnly) { + SafDownloadHandler.writeFileToSafCollisionAware( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + cleanFileName = cleanName, + variantFileName = variantName, + mimeType = mimeTypeForExt(File(variantName).extension), + srcPath = tempPath, + preservedSuffix = qualityLabel, + ) + } else { + SafDownloadHandler.writeFileToSafUnique( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + fileName = preferredName, + mimeType = mimeTypeForExt(File(preferredName).extension), + srcPath = tempPath, + preservedSuffix = qualityLabel, + ) + } ?: return SafDownloadHandler.deleteContentUri(context, state.filePath) state.filePath = writeResult.uri state.fileName = writeResult.fileName @@ -100,13 +120,32 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename( } } else { val source = File(state.filePath) - val target = uniqueLocalFile(source.parentFile, preferredName) - if (!source.renameTo(target)) { - Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}") - return + val cleanTarget = File(source.parentFile, cleanName) + val lockTarget = if (collisionOnly) cleanTarget else File(source.parentFile, preferredName) + val lockKey = lockTarget.absolutePath.lowercase(Locale.ROOT) + val lock = qualityVariantNameLocks.computeIfAbsent(lockKey) { Any() } + synchronized(lock) { + val selectedName = if (collisionOnly) { + resolveQualityVariantFilename( + fileName = logicalFileName, + stagingLabel = stagingLabel, + qualityLabel = qualityLabel, + collisionOnly = true, + cleanNameExists = cleanTarget.absolutePath != source.absolutePath && cleanTarget.exists(), + ) + } else { + preferredName + } + input.result.put("quality_variant_file_name", selectedName) + if (selectedName == state.fileName) return@synchronized + val target = uniqueLocalFile(source.parentFile, selectedName) + if (!source.renameTo(target)) { + Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}") + return@synchronized + } + state.filePath = target.absolutePath + state.fileName = target.name } - state.filePath = target.absolutePath - state.fileName = target.name } input.result.put("file_path", state.filePath) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt index 03f8e983..99bab16b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt @@ -21,6 +21,7 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec +import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension import gobackend.Gobackend import org.json.JSONObject @@ -85,32 +86,54 @@ internal fun NativeDownloadFinalizer.publishDeferredSafOutput( .ifBlank { input.request.optString("saf_relative_dir", "") } val mimeType = mimeTypeForExt(outputFile.extension) val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false) - val uniqueWrite = if (preserveQualityVariant) { - SafDownloadHandler.writeFileToSafUnique( - context = context, - treeUriStr = treeUri, - relativeDir = relativeDir, - fileName = finalName, - mimeType = mimeType, - srcPath = outputFile.absolutePath, - preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(), - ) + val qualityLabel = qualityVariantFilenameLabel(state).orEmpty() + val collisionOnly = preserveQualityVariant && + input.request.optBoolean("quality_variant_collision_only", false) + val stagingLabel = input.request.optString("quality_variant", "").trim() + val logicalVariantName = input.request.optString("saf_file_name", "") + .ifBlank { finalName } + val cleanName = removeQualityVariantStagingLabel(logicalVariantName, stagingLabel) + val variantName = if (qualityLabel.isNotEmpty()) { + applyQualityVariantFilenameLabel(logicalVariantName, stagingLabel, qualityLabel) } else { - null + finalName } - val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) { - SafDownloadHandler.writeFileToSaf( + + var alreadyExists = false + val published = when { + collisionOnly -> SafDownloadHandler.writeFileToSafCollisionAware( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + cleanFileName = cleanName, + variantFileName = variantName, + mimeType = mimeType, + srcPath = outputFile.absolutePath, + preservedSuffix = qualityLabel, + ) + preserveQualityVariant -> SafDownloadHandler.writeFileToSafUnique( context = context, treeUriStr = treeUri, relativeDir = relativeDir, fileName = finalName, mimeType = mimeType, srcPath = outputFile.absolutePath, + preservedSuffix = qualityLabel, ) - } else { - null + else -> SafDownloadHandler.writeFileToSafIfAbsent( + context = context, + treeUriStr = treeUri, + relativeDir = relativeDir, + fileName = finalName, + mimeType = mimeType, + srcPath = outputFile.absolutePath, + )?.let { result -> + alreadyExists = result.alreadyExists + SafDownloadHandler.UniqueWriteResult(result.uri, result.fileName) + } } ?: throw IllegalStateException("failed to publish deferred SAF output") - val publishedName = uniqueWrite?.fileName ?: finalName + val newUri = published.uri + val publishedName = published.fileName Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}") outputFile.delete() @@ -118,6 +141,11 @@ internal fun NativeDownloadFinalizer.publishDeferredSafOutput( state.fileName = publishedName input.result.put("file_path", newUri) input.result.put("file_name", publishedName) + if (alreadyExists) { + input.result.put("already_exists", true) + input.result.put("message", "File already exists") + input.result.put("publish_collision_existing", true) + } input.result.optJSONObject("replaygain")?.let { replayGain -> replayGain.put("file_path", newUri) replayGain.put("file_name", publishedName) 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 94d3258b..52b6c04b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -27,6 +27,11 @@ object SafDownloadHandler { private val safNameLocks = java.util.concurrent.ConcurrentHashMap() data class UniqueWriteResult(val uri: String, val fileName: String) + data class ExistingAwareWriteResult( + val uri: String, + val fileName: String, + val alreadyExists: Boolean, + ) private fun withSafNameLock( treeUriStr: String, @@ -352,6 +357,79 @@ object SafDownloadHandler { } } + fun writeFileToSafCollisionAware( + context: Context, + treeUriStr: String, + relativeDir: String, + cleanFileName: String, + variantFileName: String, + mimeType: String, + srcPath: String, + preservedSuffix: String = "", + ): UniqueWriteResult? { + val safeRelativeDir = sanitizeRelativeDir(relativeDir) + val cleanName = sanitizeFilename(cleanFileName) + val preferredVariant = sanitizeFilenamePreservingSuffix( + variantFileName, + preservedSuffix, + ) + return withSafNameLock(treeUriStr, safeRelativeDir, cleanName) { + val treeUri = Uri.parse(treeUriStr) + val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) + ?: return@withSafNameLock null + val selectedName = if (targetDir.findFile(cleanName) == null) { + cleanName + } else { + findAvailableFileName(targetDir, preferredVariant, preservedSuffix) + } + val uri = writeFileToSafLocked( + context, + treeUriStr, + safeRelativeDir, + selectedName, + srcPath, + ) ?: return@withSafNameLock null + UniqueWriteResult(uri = uri, fileName = selectedName) + } + } + + fun writeFileToSafIfAbsent( + context: Context, + treeUriStr: String, + relativeDir: String, + fileName: String, + mimeType: String, + srcPath: String, + ): ExistingAwareWriteResult? { + val safeRelativeDir = sanitizeRelativeDir(relativeDir) + val finalName = sanitizeFilename(fileName) + return withSafNameLock(treeUriStr, safeRelativeDir, finalName) { + val treeUri = Uri.parse(treeUriStr) + val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) + ?: return@withSafNameLock null + val existing = targetDir.findFile(finalName) + if (existing != null && existing.isFile && existing.length() > 0L) { + return@withSafNameLock ExistingAwareWriteResult( + uri = existing.uri.toString(), + fileName = existing.name ?: finalName, + alreadyExists = true, + ) + } + val uri = writeFileToSafLocked( + context, + treeUriStr, + safeRelativeDir, + finalName, + srcPath, + ) ?: return@withSafNameLock null + ExistingAwareWriteResult( + uri = uri, + fileName = finalName, + alreadyExists = false, + ) + } + } + private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String { val sanitized = sanitizeFilename(fileName) val trimmedSuffix = suffix.trim() diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt index c8be74cc..70c1a1a8 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -150,6 +150,38 @@ class NativeFinalizationPolicyTest { ) } + @Test + fun measuredQualityIsAddedOnlyAfterCleanNameCollision() { + val stagedName = "Artist - Track - qv_ab12cd34.flac" + assertEquals( + "Artist - Track.flac", + NativeFinalizationPolicy.removeQualityVariantStagingLabel( + fileName = stagedName, + stagingLabel = "qv_ab12cd34", + ), + ) + assertEquals( + "Artist - Track.flac", + NativeFinalizationPolicy.resolveQualityVariantFilename( + fileName = stagedName, + stagingLabel = "qv_ab12cd34", + qualityLabel = "24bit-96kHz", + collisionOnly = true, + cleanNameExists = false, + ), + ) + assertEquals( + "Artist - Track - 24bit-96kHz.flac", + NativeFinalizationPolicy.resolveQualityVariantFilename( + fileName = stagedName, + stagingLabel = "qv_ab12cd34", + qualityLabel = "24bit-96kHz", + collisionOnly = true, + cleanNameExists = true, + ), + ) + } + @Test fun deferredSafNamingNeverPublishesTheNativeCacheName() { val logicalName = NativeFinalizationPolicy.logicalOutputFileName( diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index 23ba278e..a903cb42 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -26,6 +26,23 @@ func attemptExtensionDownload( lastRetryAfterSeconds *int, ) (resp *DownloadResponse, cancelledOuter bool) { outputPath := buildOutputPathForExtension(req, ext) + if shouldReuseExistingOutput(req, outputPath) { + result := DownloadResult{FilePath: outputPath} + enrichResultQualityFromFile(&result) + built := buildDownloadSuccessResponse( + req, + result, + providerLabel, + "File already exists", + outputPath, + true, + ) + if req.ItemID != "" { + CompleteItemProgress(req.ItemID) + } + GoLog("[DownloadWithExtensionFallback] Keeping existing output instead of replacing it: %s\n", outputPath) + return &built, false + } if req.ItemID != "" { SetItemPreparingStage(req.ItemID, "resolving_stream") } diff --git a/go_backend/extension_fallback_output.go b/go_backend/extension_fallback_output.go index 61ec1386..9e52c93b 100644 --- a/go_backend/extension_fallback_output.go +++ b/go_backend/extension_fallback_output.go @@ -83,6 +83,18 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri return filepath.Join(tempDir, buildDownloadFilename(req)) } +func shouldReuseExistingOutput(req DownloadRequest, outputPath string) bool { + if req.AllowQualityVariant || isFDOutput(req.OutputFD) { + return false + } + path := strings.TrimSpace(outputPath) + if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") { + return false + } + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() && info.Size() > 0 +} + func canEmbedGenreLabel(filePath string) bool { path := strings.TrimSpace(filePath) if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") { diff --git a/go_backend/extension_fallback_output_test.go b/go_backend/extension_fallback_output_test.go new file mode 100644 index 00000000..15da77da --- /dev/null +++ b/go_backend/extension_fallback_output_test.go @@ -0,0 +1,35 @@ +package gobackend + +import ( + "os" + "path/filepath" + "testing" +) + +func TestShouldReuseExistingOutputProtectsCompletedFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "Artist - Track.flac") + if err := os.WriteFile(path, []byte("existing audio"), 0o644); err != nil { + t.Fatalf("write existing output: %v", err) + } + + if !shouldReuseExistingOutput(DownloadRequest{}, path) { + t.Fatal("expected completed output to be reused when variants are disabled") + } + if shouldReuseExistingOutput( + DownloadRequest{AllowQualityVariant: true}, + path, + ) { + t.Fatal("quality-variant staging output must remain independently writable") + } +} + +func TestShouldReuseExistingOutputIgnoresEmptyStagingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "native_saf_work.flac") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatalf("write staging output: %v", err) + } + + if shouldReuseExistingOutput(DownloadRequest{}, path) { + t.Fatal("empty staging output must not be treated as a completed download") + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4cb2a1c3..c94539b6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -6769,7 +6769,7 @@ abstract class AppLocalizations { /// Description for retaining multiple quality versions /// /// In en, this message translates to: - /// **'Add the selected quality to the filename and keep each version in download history'** + /// **'Keep every quality version; add its measured quality to the filename only when the name is already used'** String get downloadQualityVariantsDescription; /// Track menu action to download another quality version diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8da8c9bb..0fdcd0c1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4136,7 +4136,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Jede Qualitätsversion behalten; die gemessene Qualität nur dann zum Dateinamen hinzufügen, wenn der Name bereits verwendet wird'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 774c331c..16240ff5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4090,7 +4090,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Keep every quality version; add its measured quality to the filename only when the name is already used'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 4f6b92bf..625fff7a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4084,7 +4084,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Keep every quality version; add its measured quality to the filename only when the name is already used'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; @@ -8790,7 +8790,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Conservar cada versión de calidad; añadir la calidad medida al nombre solo cuando el nombre ya esté en uso'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index cc305284..4000f807 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4196,7 +4196,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Ajouter la qualité sélectionnée au nom du fichier et conserver chaque version dans l\'historique des téléchargements'; + 'Conserver chaque version de qualité ; ajouter la qualité mesurée au nom du fichier uniquement si le nom est déjà utilisé'; @override String get trackOptionDownloadQualityVariant => diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index dae5199e..680a25bc 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -4087,7 +4087,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Tambahkan kualitas yang dipilih ke nama file dan simpan setiap versi di riwayat unduhan'; + 'Simpan setiap versi kualitas; tambahkan kualitas terukur ke nama file hanya jika namanya sudah digunakan'; @override String get trackOptionDownloadQualityVariant => 'Unduh kualitas lain'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 00c9a407..55609fed 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4079,7 +4079,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + '各品質版を保持し、同じ名前が既に使用されている場合のみ測定品質をファイル名に追加します'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e6b7301a..cb8f2f9f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3968,7 +3968,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadQualityVariantsDescription => - '선택된 음질을 파일 이름에 추가하고, 각 버전을 다운로드 기록에 유지합니다'; + '각 음질 버전을 유지하고, 같은 이름이 이미 사용 중일 때만 측정된 음질을 파일 이름에 추가합니다'; @override String get trackOptionDownloadQualityVariant => '다른 음질 다운로드'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 5fbfeeee..0070ed2c 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4084,7 +4084,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Keep every quality version; add its measured quality to the filename only when the name is already used'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; @@ -8755,7 +8755,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Manter todas as versões de qualidade; adicionar a qualidade medida ao nome apenas quando o nome já estiver em uso'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index bcc5810f..00ea1aea 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4121,7 +4121,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Сохранять каждую версию качества; добавлять измеренное качество к имени файла, только если имя уже занято'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 9a45dafc..62dbfc79 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -4120,7 +4120,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Her kalite sürümünü sakla; ölçülen kaliteyi yalnızca ad zaten kullanılıyorsa dosya adına ekle'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index bd46e44c..293fa210 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4138,7 +4138,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get downloadQualityVariantsDescription => - 'Add the selected quality to the filename and keep each version in download history'; + 'Зберігати кожну версію якості; додавати виміряну якість до назви файлу лише тоді, коли назва вже використовується'; @override String get trackOptionDownloadQualityVariant => 'Download another quality'; diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 87599604..453f872b 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -6006,7 +6006,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "downloadQualityVariantsDescription": "Jede Qualitätsversion behalten; die gemessene Qualität nur dann zum Dateinamen hinzufügen, wenn der Name bereits verwendet wird", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index be91fbd0..eaf6d834 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5320,7 +5320,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "downloadQualityVariantsDescription": "Keep every quality version; add its measured quality to the filename only when the name is already used", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index cba365b0..e61db3db 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -6006,7 +6006,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "downloadQualityVariantsDescription": "Conservar cada versión de calidad; añadir la calidad medida al nombre solo cuando el nombre ya esté en uso", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 2af9e98f..6b64b097 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -6006,7 +6006,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Ajouter la qualité sélectionnée au nom du fichier et conserver chaque version dans l'historique des téléchargements", + "downloadQualityVariantsDescription": "Conserver chaque version de qualité ; ajouter la qualité mesurée au nom du fichier uniquement si le nom est déjà utilisé", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index bf56a3f8..38388571 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -5962,7 +5962,7 @@ "@downloadQualityVariants": { "description": "Pengaturan untuk menyimpan beberapa versi kualitas dari lagu yang sama" }, - "downloadQualityVariantsDescription": "Tambahkan kualitas yang dipilih ke nama file dan simpan setiap versi di riwayat unduhan", + "downloadQualityVariantsDescription": "Simpan setiap versi kualitas; tambahkan kualitas terukur ke nama file hanya jika namanya sudah digunakan", "@downloadQualityVariantsDescription": { "description": "Deskripsi penyimpanan beberapa versi kualitas" }, diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index d58a686f..5d3736b8 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -6006,7 +6006,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "downloadQualityVariantsDescription": "各品質版を保持し、同じ名前が既に使用されている場合のみ測定品質をファイル名に追加します", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 1339848a..742f17e9 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -5120,7 +5120,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "선택된 음질을 파일 이름에 추가하고, 각 버전을 다운로드 기록에 유지합니다", + "downloadQualityVariantsDescription": "각 음질 버전을 유지하고, 같은 이름이 이미 사용 중일 때만 측정된 음질을 파일 이름에 추가합니다", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 05ef7f2b..12599802 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -6006,7 +6006,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "downloadQualityVariantsDescription": "Manter todas as versões de qualidade; adicionar a qualidade medida ao nome apenas quando o nome já estiver em uso", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 151080ec..44560463 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -6006,7 +6006,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "downloadQualityVariantsDescription": "Сохранять каждую версию качества; добавлять измеренное качество к имени файла, только если имя уже занято", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 9583a9df..b241fb15 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -6006,7 +6006,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "downloadQualityVariantsDescription": "Her kalite sürümünü sakla; ölçülen kaliteyi yalnızca ad zaten kullanılıyorsa dosya adına ekle", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/l10n/arb/app_uk.arb b/lib/l10n/arb/app_uk.arb index b3ac5385..66ce6bd1 100644 --- a/lib/l10n/arb/app_uk.arb +++ b/lib/l10n/arb/app_uk.arb @@ -6006,7 +6006,7 @@ "@downloadQualityVariants": { "description": "Setting to retain multiple quality versions of the same track" }, - "downloadQualityVariantsDescription": "Add the selected quality to the filename and keep each version in download history", + "downloadQualityVariantsDescription": "Зберігати кожну версію якості; додавати виміряну якість до назви файлу лише тоді, коли назва вже використовується", "@downloadQualityVariantsDescription": { "description": "Description for retaining multiple quality versions" }, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 76dece7a..38a43676 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -126,6 +126,10 @@ final _qualityFilenameTokenPattern = RegExp( r'\{quality_variant\}', caseSensitive: false, ); +final _explicitQualityFilenameTokenPattern = RegExp( + r'\{(?:quality|quality_variant)\}', + caseSensitive: false, +); class DownloadQueueNotifier extends Notifier { Timer? _queuePersistDebounce; @@ -176,9 +180,31 @@ class DownloadQueueNotifier extends Notifier { int _queueItemSequence = 0; bool _isLoaded = false; final Set _ensuredDirs = {}; + final Map> _qualityVariantFileLocks = {}; Future? _appFolderStorageFallback; final Set _rejectedAppFolderRoots = {}; int _idleProgressPollTick = 0; + + Future _withQualityVariantFileLock( + String path, + Future Function() action, + ) async { + final key = path.toLowerCase(); + final previous = _qualityVariantFileLocks[key]; + final completer = Completer(); + final current = completer.future; + _qualityVariantFileLocks[key] = current; + if (previous != null) await previous; + try { + return await action(); + } finally { + completer.complete(); + if (identical(_qualityVariantFileLocks[key], current)) { + _qualityVariantFileLocks.remove(key); + } + } + } + bool _networkPausedByWifiOnly = false; List? _lastConnectivityResults; DateTime _lastConnectionCleanupAt = DateTime.fromMillisecondsSinceEpoch(0); @@ -444,6 +470,7 @@ class DownloadQueueNotifier extends Notifier { String? label, String? copyright, bool stageSafForDeferredPublish = false, + bool qualityVariantCollisionOnly = false, }) { final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(track, settings); final postProcessingEnabled = @@ -529,6 +556,7 @@ class DownloadQueueNotifier extends Notifier { qualityVariant: item.preserveQualityVariant ? qualityVariantStagingLabel(item.id) : '', + qualityVariantCollisionOnly: qualityVariantCollisionOnly, songLinkRegion: settings.songLinkRegion, ); } diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index f39ec6b0..70b773af 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -193,6 +193,35 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { } } + Future<({String uri, String fileName})?> _writeTempToSafCollisionAware({ + required String treeUri, + required String relativeDir, + required String cleanFileName, + required String variantFileName, + required String mimeType, + required String srcPath, + String preservedSuffix = '', + }) async { + try { + final result = await PlatformBridge.createCollisionAwareSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + cleanFileName: cleanFileName, + variantFileName: variantFileName, + mimeType: mimeType, + srcPath: srcPath, + preservedSuffix: preservedSuffix, + ); + final uri = (result['uri'] as String? ?? '').trim(); + final publishedName = (result['file_name'] as String? ?? '').trim(); + if (uri.isEmpty || publishedName.isEmpty) return null; + return (uri: uri, fileName: publishedName); + } catch (e) { + _log.w('Failed to write collision-aware temp file to SAF: $e'); + return null; + } + } + Future _writeLrcToSaf({ required String treeUri, required String relativeDir, @@ -251,6 +280,8 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { op, bool avoidOverwrite = false, String preservedSuffix = '', + String? collisionCleanFileName, + String? collisionVariantFileName, void Function(String fileName)? onPublishedFileName, }) async { final tempPath = await _copySafToTemp(uri); @@ -267,7 +298,21 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { final dotIndex = fileName.lastIndexOf('.'); final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : ''; String? newUri; - if (avoidOverwrite) { + if (collisionCleanFileName != null && collisionVariantFileName != null) { + final published = await _writeTempToSafCollisionAware( + treeUri: treeUri, + relativeDir: relativeDir, + cleanFileName: collisionCleanFileName, + variantFileName: collisionVariantFileName, + mimeType: _mimeTypeForExt(ext), + srcPath: outPath, + preservedSuffix: preservedSuffix, + ); + newUri = published?.uri; + if (published != null) { + onPublishedFileName?.call(published.fileName); + } + } else if (avoidOverwrite) { final published = await _writeTempToSafUnique( treeUri: treeUri, relativeDir: relativeDir, @@ -321,6 +366,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { String? downloadTreeUri, String? safRelativeDir, String? fileName, + bool collisionOnly = false, }) async { if (!item.preserveQualityVariant || result['already_exists'] == true) { return _QualityVariantFileOutcome(filePath: filePath, fileName: fileName); @@ -391,11 +437,16 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { final currentFileName = storageMode == 'saf' && isContentUri(filePath) ? (fileName ?? result['file_name']?.toString() ?? '') : (localPathSegments.isEmpty ? '' : localPathSegments.last); - final preferredFileName = applyQualityVariantFilenameLabel( + final variantFileName = applyQualityVariantFilenameLabel( fileName: currentFileName, stagingLabel: stagingLabel, qualityLabel: qualityLabel, ); + final cleanFileName = removeQualityVariantStagingLabel( + fileName: currentFileName, + stagingLabel: stagingLabel, + ); + final preferredFileName = variantFileName; if (preferredFileName == currentFileName) { return _QualityVariantFileOutcome( filePath: filePath, @@ -417,8 +468,10 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { uri: filePath, treeUri: downloadTreeUri, relativeDir: safRelativeDir ?? '', - avoidOverwrite: true, + avoidOverwrite: !collisionOnly, preservedSuffix: qualityLabel, + collisionCleanFileName: collisionOnly ? cleanFileName : null, + collisionVariantFileName: collisionOnly ? variantFileName : null, onPublishedFileName: (name) => publishedFileName = name, op: (tempPath, addCleanup) async => (tempPath, preferredFileName), ); @@ -441,44 +494,70 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { final source = File(filePath); final parent = source.parent; - var target = File( - '${parent.path}${Platform.pathSeparator}$preferredFileName', + final cleanTarget = File( + '${parent.path}${Platform.pathSeparator}$cleanFileName', ); - var counter = 2; - while (await target.exists() && target.path != source.path) { - final dotIndex = preferredFileName.lastIndexOf('.'); - final hasExtension = dotIndex > 0; - final stem = hasExtension - ? preferredFileName.substring(0, dotIndex) + final lockTarget = collisionOnly + ? cleanTarget + : File('${parent.path}${Platform.pathSeparator}$preferredFileName'); + return _withQualityVariantFileLock(lockTarget.path, () async { + final selectedFileName = collisionOnly + ? resolveQualityVariantFilename( + fileName: currentFileName, + stagingLabel: stagingLabel, + qualityLabel: qualityLabel, + collisionOnly: true, + cleanNameExists: + cleanTarget.path != source.path && await cleanTarget.exists(), + ) : preferredFileName; - final extension = hasExtension - ? preferredFileName.substring(dotIndex) - : ''; - target = File( - '${parent.path}${Platform.pathSeparator}$stem ($counter)$extension', + if (selectedFileName == currentFileName) { + return _QualityVariantFileOutcome( + filePath: filePath, + fileName: fileName, + metadata: metadata, + ); + } + + var target = File( + '${parent.path}${Platform.pathSeparator}$selectedFileName', ); - counter++; - } - try { - final renamed = await source.rename(target.path); - result['file_path'] = renamed.path; - final renamedSegments = renamed.uri.pathSegments; - result['file_name'] = renamedSegments.isEmpty - ? null - : renamedSegments.last; - return _QualityVariantFileOutcome( - filePath: renamed.path, - fileName: result['file_name'] as String?, - metadata: metadata, - ); - } catch (e) { - _log.w('Failed to apply measured quality filename: $e'); - return _QualityVariantFileOutcome( - filePath: filePath, - fileName: fileName, - metadata: metadata, - ); - } + var counter = 2; + while (await target.exists() && target.path != source.path) { + final dotIndex = selectedFileName.lastIndexOf('.'); + final hasExtension = dotIndex > 0; + final stem = hasExtension + ? selectedFileName.substring(0, dotIndex) + : selectedFileName; + final extension = hasExtension + ? selectedFileName.substring(dotIndex) + : ''; + target = File( + '${parent.path}${Platform.pathSeparator}$stem ($counter)$extension', + ); + counter++; + } + try { + final renamed = await source.rename(target.path); + result['file_path'] = renamed.path; + final renamedSegments = renamed.uri.pathSegments; + result['file_name'] = renamedSegments.isEmpty + ? null + : renamedSegments.last; + return _QualityVariantFileOutcome( + filePath: renamed.path, + fileName: result['file_name'] as String?, + metadata: metadata, + ); + } catch (e) { + _log.w('Failed to apply measured quality filename: $e'); + return _QualityVariantFileOutcome( + filePath: filePath, + fileName: fileName, + metadata: metadata, + ); + } + }); } /// Shared decrypt finalize used by both the inline single-item pipeline diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 264a27e5..ffdc1721 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -11,6 +11,7 @@ class _NativeWorkerRequestContext { final String? downloadTreeUri; final String? safRelativeDir; final String? safFileName; + final bool qualityVariantCollisionOnly; const _NativeWorkerRequestContext({ required this.item, @@ -22,6 +23,7 @@ class _NativeWorkerRequestContext { this.downloadTreeUri, this.safRelativeDir, this.safFileName, + this.qualityVariantCollisionOnly = false, }); } @@ -730,6 +732,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { item, baseFilenameFormat, ); + final qualityVariantCollisionOnly = + item.preserveQualityVariant && + !_explicitQualityFilenameTokenPattern.hasMatch(baseFilenameFormat); if (isSafMode) { safFileName = await _buildSafFileNameForItem( item, @@ -775,6 +780,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { label: extendedMetadata?.label, copyright: extendedMetadata?.copyright, stageSafForDeferredPublish: isSafMode, + qualityVariantCollisionOnly: qualityVariantCollisionOnly, ).withStrategy(useExtensions: true, useFallback: state.autoFallback); return _NativeWorkerRequestContext( @@ -787,6 +793,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { downloadTreeUri: isSafMode ? settings.downloadTreeUri : null, safRelativeDir: isSafMode ? outputDir : null, safFileName: safFileName, + qualityVariantCollisionOnly: qualityVariantCollisionOnly, ); } @@ -1177,6 +1184,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { downloadTreeUri: context.downloadTreeUri, safRelativeDir: context.safRelativeDir, fileName: result['file_name'] as String? ?? context.safFileName, + collisionOnly: context.qualityVariantCollisionOnly, ); filePath = variantOutcome.filePath; actualBitDepth = readPositiveInt(result['actual_bit_depth']); diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index dd34505e..d9d32a31 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -100,6 +100,7 @@ class _DownloadRun { String effectiveOutputDir = ''; String? appOutputDir; String? safFileName; + bool qualityVariantCollisionOnly = false; String? safBaseName; String? finalSafFileName; @@ -419,6 +420,9 @@ class _DownloadRun { item, baseFilenameFormat, ); + qualityVariantCollisionOnly = + item.preserveQualityVariant && + !_explicitQualityFilenameTokenPattern.hasMatch(baseFilenameFormat); if (isSafMode) { final builtSafFileName = await n._buildSafFileNameForItem( item, @@ -549,6 +553,7 @@ class _DownloadRun { genre: genre, label: label, copyright: copyright, + qualityVariantCollisionOnly: qualityVariantCollisionOnly, ); return PlatformBridge.downloadByStrategy( @@ -705,6 +710,7 @@ class _DownloadRun { downloadTreeUri: settings.downloadTreeUri, safRelativeDir: effectiveOutputDir, fileName: finalSafFileName ?? safFileName, + collisionOnly: qualityVariantCollisionOnly, ); filePath = variantOutcome.filePath; if (variantOutcome.fileName != null) { diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 104c33d1..cdeca4d1 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -50,6 +50,7 @@ class DownloadRequestPayload { final bool requiresContainerConversion; final bool allowQualityVariant; final String qualityVariant; + final bool qualityVariantCollisionOnly; final String songLinkRegion; const DownloadRequestPayload({ @@ -102,6 +103,7 @@ class DownloadRequestPayload { this.requiresContainerConversion = false, this.allowQualityVariant = false, this.qualityVariant = '', + this.qualityVariantCollisionOnly = false, this.songLinkRegion = 'US', }); @@ -156,6 +158,7 @@ class DownloadRequestPayload { 'requires_container_conversion': requiresContainerConversion, 'allow_quality_variant': allowQualityVariant, 'quality_variant': qualityVariant, + 'quality_variant_collision_only': qualityVariantCollisionOnly, 'songlink_region': songLinkRegion, }; } @@ -214,6 +217,7 @@ class DownloadRequestPayload { requiresContainerConversion: requiresContainerConversion, allowQualityVariant: allowQualityVariant, qualityVariant: qualityVariant, + qualityVariantCollisionOnly: qualityVariantCollisionOnly, songLinkRegion: songLinkRegion, ); } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 53f613a8..5c0825c6 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -678,6 +678,26 @@ class PlatformBridge { }); } + static Future> createCollisionAwareSafFileFromPath({ + required String treeUri, + required String relativeDir, + required String cleanFileName, + required String variantFileName, + required String mimeType, + required String srcPath, + String preservedSuffix = '', + }) { + return _invokeMap('safCreateCollisionAwareFromPath', { + 'tree_uri': treeUri, + 'relative_dir': relativeDir, + 'clean_file_name': cleanFileName, + 'variant_file_name': variantFileName, + 'mime_type': mimeType, + 'src_path': srcPath, + 'preserved_suffix': preservedSuffix, + }); + } + static Future openContentUri(String uri, {String mimeType = ''}) async { await _channel.invokeMethod('openContentUri', { 'uri': uri, @@ -1069,9 +1089,10 @@ class PlatformBridge { static Future> getNativeDownloadWorkerSnapshot({ int sinceStateSerial = 0, }) async { - final result = await _channel.invokeMethod('getNativeDownloadWorkerSnapshot', { - 'since_state_serial': sinceStateSerial, - }); + final result = await _channel.invokeMethod( + 'getNativeDownloadWorkerSnapshot', + {'since_state_serial': sinceStateSerial}, + ); return _decodeMapResult(result); } @@ -1212,9 +1233,7 @@ class PlatformBridge { }); } - static Future> loadExtensionsFromDir( - String dirPath, - ) { + static Future> loadExtensionsFromDir(String dirPath) { _log.d('loadExtensionsFromDir: $dirPath'); return _invokeMap('loadExtensionsFromDir', {'dir_path': dirPath}); } @@ -1249,9 +1268,7 @@ class PlatformBridge { return _invokeMap('upgradeExtension', {'file_path': filePath}); } - static Future> checkExtensionUpgrade( - String filePath, - ) { + static Future> checkExtensionUpgrade(String filePath) { _log.d('checkExtensionUpgrade: $filePath'); return _invokeMap('checkExtensionUpgrade', {'file_path': filePath}); } @@ -1311,15 +1328,11 @@ class PlatformBridge { return _decodeStringListResult(result, 'getMetadataProviderPriority'); } - static Future> getExtensionSettings( - String extensionId, - ) { + static Future> getExtensionSettings(String extensionId) { return _invokeMap('getExtensionSettings', {'extension_id': extensionId}); } - static Future> checkExtensionHealth( - String extensionId, - ) { + static Future> checkExtensionHealth(String extensionId) { return _invokeMap('checkExtensionHealth', {'extension_id': extensionId}); } diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 6e1373c9..1022761f 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -222,6 +222,44 @@ String applyQualityVariantFilenameLabel({ return '$stem - $qualityLabel$extension'; } +String removeQualityVariantStagingLabel({ + required String fileName, + required String stagingLabel, +}) { + if (stagingLabel.isEmpty || !fileName.contains(stagingLabel)) { + return fileName; + } + final dotIndex = fileName.lastIndexOf('.'); + final hasExtension = dotIndex > 0; + final stem = hasExtension ? fileName.substring(0, dotIndex) : fileName; + final extension = hasExtension ? fileName.substring(dotIndex) : ''; + final cleanedStem = stem + .replaceAll(stagingLabel, '') + .replaceFirst(RegExp(r'[\s_-]+$'), '') + .trim(); + return '${cleanedStem.isEmpty ? 'track' : cleanedStem}$extension'; +} + +String resolveQualityVariantFilename({ + required String fileName, + required String stagingLabel, + required String qualityLabel, + required bool collisionOnly, + required bool cleanNameExists, +}) { + if (!collisionOnly || cleanNameExists) { + return applyQualityVariantFilenameLabel( + fileName: fileName, + stagingLabel: stagingLabel, + qualityLabel: qualityLabel, + ); + } + return removeQualityVariantStagingLabel( + fileName: fileName, + stagingLabel: stagingLabel, + ); +} + String lossyFormatForSetting(String value) { final normalized = value.trim().toLowerCase(); if (normalized.startsWith('opus')) return 'opus'; diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 59bdc97b..a8c570a9 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -333,6 +333,38 @@ void main() { 'Post Processed Song - 16bit-44.1kHz.flac', ); }); + + test('adds measured quality only when a clean filename collides', () { + const staging = 'qv_12345678'; + const stagedName = 'Artist - Song - qv_12345678.flac'; + expect( + removeQualityVariantStagingLabel( + fileName: stagedName, + stagingLabel: staging, + ), + 'Artist - Song.flac', + ); + expect( + resolveQualityVariantFilename( + fileName: stagedName, + stagingLabel: staging, + qualityLabel: '24bit-96kHz', + collisionOnly: true, + cleanNameExists: false, + ), + 'Artist - Song.flac', + ); + expect( + resolveQualityVariantFilename( + fileName: stagedName, + stagingLabel: staging, + qualityLabel: '24bit-96kHz', + collisionOnly: true, + cleanNameExists: true, + ), + 'Artist - Song - 24bit-96kHz.flac', + ); + }); }); group('Library collections', () { @@ -783,6 +815,7 @@ void main() { outputExt: '.flac', allowQualityVariant: true, qualityVariant: 'qv_12345678', + qualityVariantCollisionOnly: true, songLinkRegion: 'ID', ); @@ -836,6 +869,7 @@ void main() { 'requires_container_conversion': false, 'allow_quality_variant': true, 'quality_variant': 'qv_12345678', + 'quality_variant_collision_only': true, 'songlink_region': 'ID', }); }); @@ -859,6 +893,10 @@ void main() { expect(updated.filenameFormat, payload.filenameFormat); expect(updated.allowQualityVariant, payload.allowQualityVariant); expect(updated.qualityVariant, payload.qualityVariant); + expect( + updated.qualityVariantCollisionOnly, + payload.qualityVariantCollisionOnly, + ); }); });