fix(download): suffix only colliding quality variants

This commit is contained in:
zarzet
2026-07-29 02:43:35 +07:00
parent 1f55523b64
commit e7fbaf7b75
41 changed files with 629 additions and 110 deletions
@@ -1052,6 +1052,38 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"safCreateCollisionAwareFromPath" -> {
val treeUriStr = call.argument<String>("tree_uri") ?: ""
val relativeDir = call.argument<String>("relative_dir") ?: ""
val cleanFileName = call.argument<String>("clean_file_name") ?: ""
val variantFileName = call.argument<String>("variant_file_name") ?: ""
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
val srcPath = call.argument<String>("src_path") ?: ""
val preservedSuffix = call.argument<String>("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" -> { "openContentUri" -> {
val uriStr = call.argument<String>("uri") ?: "" val uriStr = call.argument<String>("uri") ?: ""
val mimeType = call.argument<String>("mime_type") ?: "" val mimeType = call.argument<String>("mime_type") ?: ""
@@ -44,6 +44,7 @@ object NativeDownloadFinalizer {
internal val nativeFFmpegSessionIds = mutableSetOf<Long>() internal val nativeFFmpegSessionIds = mutableSetOf<Long>()
internal val activeFFmpegSessionLock = Any() internal val activeFFmpegSessionLock = Any()
internal val ffmpegCompleteCallbackLock = Any() internal val ffmpegCompleteCallbackLock = Any()
internal val qualityVariantNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
internal var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null internal var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null
internal val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session -> internal val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session ->
val isNativeSession = synchronized(activeFFmpegSessionLock) { val isNativeSession = synchronized(activeFFmpegSessionLock) {
@@ -257,6 +258,7 @@ object NativeDownloadFinalizer {
.optBoolean("allow_quality_variant", false) .optBoolean("allow_quality_variant", false)
val history = if ( val history = if (
saveDownloadHistory && saveDownloadHistory &&
!result.optBoolean("publish_collision_existing", false) &&
!(preserveQualityVariant && result.optBoolean("already_exists", false)) !(preserveQualityVariant && result.optBoolean("already_exists", false))
) { ) {
try { try {
@@ -157,6 +157,36 @@ internal object NativeFinalizationPolicy {
return "$stem - $qualityLabel$extension" 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 * Returns the user-facing name that a deferred SAF download was assigned
* before its audio was materialized in the app cache. Container and * before its audio was materialized in the app cache. Container and
@@ -21,6 +21,8 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.logicalOutputFileName 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.normalizeAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
import gobackend.Gobackend import gobackend.Gobackend
@@ -68,11 +70,14 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
requestSafFileName = input.request.optString("saf_file_name", ""), requestSafFileName = input.request.optString("saf_file_name", ""),
currentFileName = state.fileName, currentFileName = state.fileName,
) )
val preferredName = applyQualityVariantFilenameLabel( val variantName = applyQualityVariantFilenameLabel(
fileName = logicalFileName, fileName = logicalFileName,
stagingLabel = stagingLabel, stagingLabel = stagingLabel,
qualityLabel = qualityLabel, 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 if (preferredName == logicalFileName && preferredName == state.fileName) return
input.result.put("quality_variant_file_name", preferredName) input.result.put("quality_variant_file_name", preferredName)
if (isDeferredSafPublish(input)) { if (isDeferredSafPublish(input)) {
@@ -83,15 +88,30 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
if (state.filePath.startsWith("content://")) { if (state.filePath.startsWith("content://")) {
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
try { try {
val writeResult = SafDownloadHandler.writeFileToSafUnique( val treeUri = input.request.optString("saf_tree_uri", "")
context = context, val relativeDir = input.request.optString("saf_relative_dir", "")
treeUriStr = input.request.optString("saf_tree_uri", ""), val writeResult = if (collisionOnly) {
relativeDir = input.request.optString("saf_relative_dir", ""), SafDownloadHandler.writeFileToSafCollisionAware(
fileName = preferredName, context = context,
mimeType = mimeTypeForExt(File(preferredName).extension), treeUriStr = treeUri,
srcPath = tempPath, relativeDir = relativeDir,
preservedSuffix = qualityLabel, cleanFileName = cleanName,
) ?: return 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) SafDownloadHandler.deleteContentUri(context, state.filePath)
state.filePath = writeResult.uri state.filePath = writeResult.uri
state.fileName = writeResult.fileName state.fileName = writeResult.fileName
@@ -100,13 +120,32 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
} }
} else { } else {
val source = File(state.filePath) val source = File(state.filePath)
val target = uniqueLocalFile(source.parentFile, preferredName) val cleanTarget = File(source.parentFile, cleanName)
if (!source.renameTo(target)) { val lockTarget = if (collisionOnly) cleanTarget else File(source.parentFile, preferredName)
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}") val lockKey = lockTarget.absolutePath.lowercase(Locale.ROOT)
return 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) input.result.put("file_path", state.filePath)
@@ -21,6 +21,7 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
import gobackend.Gobackend import gobackend.Gobackend
import org.json.JSONObject import org.json.JSONObject
@@ -85,32 +86,54 @@ internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
.ifBlank { input.request.optString("saf_relative_dir", "") } .ifBlank { input.request.optString("saf_relative_dir", "") }
val mimeType = mimeTypeForExt(outputFile.extension) val mimeType = mimeTypeForExt(outputFile.extension)
val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false) val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
val uniqueWrite = if (preserveQualityVariant) { val qualityLabel = qualityVariantFilenameLabel(state).orEmpty()
SafDownloadHandler.writeFileToSafUnique( val collisionOnly = preserveQualityVariant &&
context = context, input.request.optBoolean("quality_variant_collision_only", false)
treeUriStr = treeUri, val stagingLabel = input.request.optString("quality_variant", "").trim()
relativeDir = relativeDir, val logicalVariantName = input.request.optString("saf_file_name", "")
fileName = finalName, .ifBlank { finalName }
mimeType = mimeType, val cleanName = removeQualityVariantStagingLabel(logicalVariantName, stagingLabel)
srcPath = outputFile.absolutePath, val variantName = if (qualityLabel.isNotEmpty()) {
preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(), applyQualityVariantFilenameLabel(logicalVariantName, stagingLabel, qualityLabel)
)
} else { } 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, context = context,
treeUriStr = treeUri, treeUriStr = treeUri,
relativeDir = relativeDir, relativeDir = relativeDir,
fileName = finalName, fileName = finalName,
mimeType = mimeType, mimeType = mimeType,
srcPath = outputFile.absolutePath, srcPath = outputFile.absolutePath,
preservedSuffix = qualityLabel,
) )
} else { else -> SafDownloadHandler.writeFileToSafIfAbsent(
null 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") } ?: 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()}") Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
outputFile.delete() outputFile.delete()
@@ -118,6 +141,11 @@ internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
state.fileName = publishedName state.fileName = publishedName
input.result.put("file_path", newUri) input.result.put("file_path", newUri)
input.result.put("file_name", publishedName) 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 -> input.result.optJSONObject("replaygain")?.let { replayGain ->
replayGain.put("file_path", newUri) replayGain.put("file_path", newUri)
replayGain.put("file_name", publishedName) replayGain.put("file_name", publishedName)
@@ -27,6 +27,11 @@ object SafDownloadHandler {
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>() private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
data class UniqueWriteResult(val uri: String, val fileName: String) data class UniqueWriteResult(val uri: String, val fileName: String)
data class ExistingAwareWriteResult(
val uri: String,
val fileName: String,
val alreadyExists: Boolean,
)
private fun <T> withSafNameLock( private fun <T> withSafNameLock(
treeUriStr: String, 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 { private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
val sanitized = sanitizeFilename(fileName) val sanitized = sanitizeFilename(fileName)
val trimmedSuffix = suffix.trim() val trimmedSuffix = suffix.trim()
@@ -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 @Test
fun deferredSafNamingNeverPublishesTheNativeCacheName() { fun deferredSafNamingNeverPublishesTheNativeCacheName() {
val logicalName = NativeFinalizationPolicy.logicalOutputFileName( val logicalName = NativeFinalizationPolicy.logicalOutputFileName(
+17
View File
@@ -26,6 +26,23 @@ func attemptExtensionDownload(
lastRetryAfterSeconds *int, lastRetryAfterSeconds *int,
) (resp *DownloadResponse, cancelledOuter bool) { ) (resp *DownloadResponse, cancelledOuter bool) {
outputPath := buildOutputPathForExtension(req, ext) 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 != "" { if req.ItemID != "" {
SetItemPreparingStage(req.ItemID, "resolving_stream") SetItemPreparingStage(req.ItemID, "resolving_stream")
} }
+12
View File
@@ -83,6 +83,18 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
return filepath.Join(tempDir, buildDownloadFilename(req)) 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 { func canEmbedGenreLabel(filePath string) bool {
path := strings.TrimSpace(filePath) path := strings.TrimSpace(filePath)
if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") { if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") {
@@ -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")
}
}
+1 -1
View File
@@ -6769,7 +6769,7 @@ abstract class AppLocalizations {
/// Description for retaining multiple quality versions /// Description for retaining multiple quality versions
/// ///
/// In en, this message translates to: /// 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; String get downloadQualityVariantsDescription;
/// Track menu action to download another quality version /// Track menu action to download another quality version
+1 -1
View File
@@ -4136,7 +4136,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
+1 -1
View File
@@ -4090,7 +4090,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
+2 -2
View File
@@ -4084,7 +4084,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
@@ -8790,7 +8790,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
+1 -1
View File
@@ -4196,7 +4196,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => String get trackOptionDownloadQualityVariant =>
+1 -1
View File
@@ -4087,7 +4087,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => 'Unduh kualitas lain'; String get trackOptionDownloadQualityVariant => 'Unduh kualitas lain';
+1 -1
View File
@@ -4079,7 +4079,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => String get downloadQualityVariantsDescription =>
'Add the selected quality to the filename and keep each version in download history'; '各品質版を保持し、同じ名前が既に使用されている場合のみ測定品質をファイル名に追加します';
@override @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
+1 -1
View File
@@ -3968,7 +3968,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => String get downloadQualityVariantsDescription =>
'선택된 음질을 파일 이름에 추가하고, 각 버전을 다운로드 기록에 유지합니다'; '각 음질 버전을 유지하고, 같은 이름이 이미 사용 중일 때만 측정된 음질을 파일 이름에 추가합니다';
@override @override
String get trackOptionDownloadQualityVariant => '다른 음질 다운로드'; String get trackOptionDownloadQualityVariant => '다른 음질 다운로드';
+2 -2
View File
@@ -4084,7 +4084,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
@@ -8755,7 +8755,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
+1 -1
View File
@@ -4121,7 +4121,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => String get downloadQualityVariantsDescription =>
'Add the selected quality to the filename and keep each version in download history'; 'Сохранять каждую версию качества; добавлять измеренное качество к имени файла, только если имя уже занято';
@override @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
+1 -1
View File
@@ -4120,7 +4120,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => 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 @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
+1 -1
View File
@@ -4138,7 +4138,7 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get downloadQualityVariantsDescription => String get downloadQualityVariantsDescription =>
'Add the selected quality to the filename and keep each version in download history'; 'Зберігати кожну версію якості; додавати виміряну якість до назви файлу лише тоді, коли назва вже використовується';
@override @override
String get trackOptionDownloadQualityVariant => 'Download another quality'; String get trackOptionDownloadQualityVariant => 'Download another quality';
+1 -1
View File
@@ -6006,7 +6006,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -5320,7 +5320,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -6006,7 +6006,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -6006,7 +6006,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -5962,7 +5962,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Pengaturan untuk menyimpan beberapa versi kualitas dari lagu yang sama" "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": { "@downloadQualityVariantsDescription": {
"description": "Deskripsi penyimpanan beberapa versi kualitas" "description": "Deskripsi penyimpanan beberapa versi kualitas"
}, },
+1 -1
View File
@@ -6006,7 +6006,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -5120,7 +5120,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "description": "Setting to retain multiple quality versions of the same track"
}, },
"downloadQualityVariantsDescription": "선택된 음질을 파일 이름에 추가하고, 각 버전을 다운로드 기록에 유지합니다", "downloadQualityVariantsDescription": "각 음질 버전을 유지하고, 같은 이름이 이미 사용 중일 때만 측정된 음질을 파일 이름에 추가합니다",
"@downloadQualityVariantsDescription": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -6006,7 +6006,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -6006,7 +6006,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -6006,7 +6006,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
+1 -1
View File
@@ -6006,7 +6006,7 @@
"@downloadQualityVariants": { "@downloadQualityVariants": {
"description": "Setting to retain multiple quality versions of the same track" "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": { "@downloadQualityVariantsDescription": {
"description": "Description for retaining multiple quality versions" "description": "Description for retaining multiple quality versions"
}, },
@@ -126,6 +126,10 @@ final _qualityFilenameTokenPattern = RegExp(
r'\{quality_variant\}', r'\{quality_variant\}',
caseSensitive: false, caseSensitive: false,
); );
final _explicitQualityFilenameTokenPattern = RegExp(
r'\{(?:quality|quality_variant)\}',
caseSensitive: false,
);
class DownloadQueueNotifier extends Notifier<DownloadQueueState> { class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _queuePersistDebounce; Timer? _queuePersistDebounce;
@@ -176,9 +180,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _queueItemSequence = 0; int _queueItemSequence = 0;
bool _isLoaded = false; bool _isLoaded = false;
final Set<String> _ensuredDirs = {}; final Set<String> _ensuredDirs = {};
final Map<String, Future<void>> _qualityVariantFileLocks = {};
Future<String>? _appFolderStorageFallback; Future<String>? _appFolderStorageFallback;
final Set<String> _rejectedAppFolderRoots = {}; final Set<String> _rejectedAppFolderRoots = {};
int _idleProgressPollTick = 0; int _idleProgressPollTick = 0;
Future<T> _withQualityVariantFileLock<T>(
String path,
Future<T> Function() action,
) async {
final key = path.toLowerCase();
final previous = _qualityVariantFileLocks[key];
final completer = Completer<void>();
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; bool _networkPausedByWifiOnly = false;
List<ConnectivityResult>? _lastConnectivityResults; List<ConnectivityResult>? _lastConnectivityResults;
DateTime _lastConnectionCleanupAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastConnectionCleanupAt = DateTime.fromMillisecondsSinceEpoch(0);
@@ -444,6 +470,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? label, String? label,
String? copyright, String? copyright,
bool stageSafForDeferredPublish = false, bool stageSafForDeferredPublish = false,
bool qualityVariantCollisionOnly = false,
}) { }) {
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(track, settings); final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(track, settings);
final postProcessingEnabled = final postProcessingEnabled =
@@ -529,6 +556,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
qualityVariant: item.preserveQualityVariant qualityVariant: item.preserveQualityVariant
? qualityVariantStagingLabel(item.id) ? qualityVariantStagingLabel(item.id)
: '', : '',
qualityVariantCollisionOnly: qualityVariantCollisionOnly,
songLinkRegion: settings.songLinkRegion, songLinkRegion: settings.songLinkRegion,
); );
} }
@@ -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<void> _writeLrcToSaf({ Future<void> _writeLrcToSaf({
required String treeUri, required String treeUri,
required String relativeDir, required String relativeDir,
@@ -251,6 +280,8 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
op, op,
bool avoidOverwrite = false, bool avoidOverwrite = false,
String preservedSuffix = '', String preservedSuffix = '',
String? collisionCleanFileName,
String? collisionVariantFileName,
void Function(String fileName)? onPublishedFileName, void Function(String fileName)? onPublishedFileName,
}) async { }) async {
final tempPath = await _copySafToTemp(uri); final tempPath = await _copySafToTemp(uri);
@@ -267,7 +298,21 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
final dotIndex = fileName.lastIndexOf('.'); final dotIndex = fileName.lastIndexOf('.');
final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : ''; final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : '';
String? newUri; 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( final published = await _writeTempToSafUnique(
treeUri: treeUri, treeUri: treeUri,
relativeDir: relativeDir, relativeDir: relativeDir,
@@ -321,6 +366,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
String? downloadTreeUri, String? downloadTreeUri,
String? safRelativeDir, String? safRelativeDir,
String? fileName, String? fileName,
bool collisionOnly = false,
}) async { }) async {
if (!item.preserveQualityVariant || result['already_exists'] == true) { if (!item.preserveQualityVariant || result['already_exists'] == true) {
return _QualityVariantFileOutcome(filePath: filePath, fileName: fileName); return _QualityVariantFileOutcome(filePath: filePath, fileName: fileName);
@@ -391,11 +437,16 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
final currentFileName = storageMode == 'saf' && isContentUri(filePath) final currentFileName = storageMode == 'saf' && isContentUri(filePath)
? (fileName ?? result['file_name']?.toString() ?? '') ? (fileName ?? result['file_name']?.toString() ?? '')
: (localPathSegments.isEmpty ? '' : localPathSegments.last); : (localPathSegments.isEmpty ? '' : localPathSegments.last);
final preferredFileName = applyQualityVariantFilenameLabel( final variantFileName = applyQualityVariantFilenameLabel(
fileName: currentFileName, fileName: currentFileName,
stagingLabel: stagingLabel, stagingLabel: stagingLabel,
qualityLabel: qualityLabel, qualityLabel: qualityLabel,
); );
final cleanFileName = removeQualityVariantStagingLabel(
fileName: currentFileName,
stagingLabel: stagingLabel,
);
final preferredFileName = variantFileName;
if (preferredFileName == currentFileName) { if (preferredFileName == currentFileName) {
return _QualityVariantFileOutcome( return _QualityVariantFileOutcome(
filePath: filePath, filePath: filePath,
@@ -417,8 +468,10 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
uri: filePath, uri: filePath,
treeUri: downloadTreeUri, treeUri: downloadTreeUri,
relativeDir: safRelativeDir ?? '', relativeDir: safRelativeDir ?? '',
avoidOverwrite: true, avoidOverwrite: !collisionOnly,
preservedSuffix: qualityLabel, preservedSuffix: qualityLabel,
collisionCleanFileName: collisionOnly ? cleanFileName : null,
collisionVariantFileName: collisionOnly ? variantFileName : null,
onPublishedFileName: (name) => publishedFileName = name, onPublishedFileName: (name) => publishedFileName = name,
op: (tempPath, addCleanup) async => (tempPath, preferredFileName), op: (tempPath, addCleanup) async => (tempPath, preferredFileName),
); );
@@ -441,44 +494,70 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
final source = File(filePath); final source = File(filePath);
final parent = source.parent; final parent = source.parent;
var target = File( final cleanTarget = File(
'${parent.path}${Platform.pathSeparator}$preferredFileName', '${parent.path}${Platform.pathSeparator}$cleanFileName',
); );
var counter = 2; final lockTarget = collisionOnly
while (await target.exists() && target.path != source.path) { ? cleanTarget
final dotIndex = preferredFileName.lastIndexOf('.'); : File('${parent.path}${Platform.pathSeparator}$preferredFileName');
final hasExtension = dotIndex > 0; return _withQualityVariantFileLock(lockTarget.path, () async {
final stem = hasExtension final selectedFileName = collisionOnly
? preferredFileName.substring(0, dotIndex) ? resolveQualityVariantFilename(
fileName: currentFileName,
stagingLabel: stagingLabel,
qualityLabel: qualityLabel,
collisionOnly: true,
cleanNameExists:
cleanTarget.path != source.path && await cleanTarget.exists(),
)
: preferredFileName; : preferredFileName;
final extension = hasExtension if (selectedFileName == currentFileName) {
? preferredFileName.substring(dotIndex) return _QualityVariantFileOutcome(
: ''; filePath: filePath,
target = File( fileName: fileName,
'${parent.path}${Platform.pathSeparator}$stem ($counter)$extension', metadata: metadata,
);
}
var target = File(
'${parent.path}${Platform.pathSeparator}$selectedFileName',
); );
counter++; var counter = 2;
} while (await target.exists() && target.path != source.path) {
try { final dotIndex = selectedFileName.lastIndexOf('.');
final renamed = await source.rename(target.path); final hasExtension = dotIndex > 0;
result['file_path'] = renamed.path; final stem = hasExtension
final renamedSegments = renamed.uri.pathSegments; ? selectedFileName.substring(0, dotIndex)
result['file_name'] = renamedSegments.isEmpty : selectedFileName;
? null final extension = hasExtension
: renamedSegments.last; ? selectedFileName.substring(dotIndex)
return _QualityVariantFileOutcome( : '';
filePath: renamed.path, target = File(
fileName: result['file_name'] as String?, '${parent.path}${Platform.pathSeparator}$stem ($counter)$extension',
metadata: metadata, );
); counter++;
} catch (e) { }
_log.w('Failed to apply measured quality filename: $e'); try {
return _QualityVariantFileOutcome( final renamed = await source.rename(target.path);
filePath: filePath, result['file_path'] = renamed.path;
fileName: fileName, final renamedSegments = renamed.uri.pathSegments;
metadata: metadata, 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 /// Shared decrypt finalize used by both the inline single-item pipeline
@@ -11,6 +11,7 @@ class _NativeWorkerRequestContext {
final String? downloadTreeUri; final String? downloadTreeUri;
final String? safRelativeDir; final String? safRelativeDir;
final String? safFileName; final String? safFileName;
final bool qualityVariantCollisionOnly;
const _NativeWorkerRequestContext({ const _NativeWorkerRequestContext({
required this.item, required this.item,
@@ -22,6 +23,7 @@ class _NativeWorkerRequestContext {
this.downloadTreeUri, this.downloadTreeUri,
this.safRelativeDir, this.safRelativeDir,
this.safFileName, this.safFileName,
this.qualityVariantCollisionOnly = false,
}); });
} }
@@ -730,6 +732,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
item, item,
baseFilenameFormat, baseFilenameFormat,
); );
final qualityVariantCollisionOnly =
item.preserveQualityVariant &&
!_explicitQualityFilenameTokenPattern.hasMatch(baseFilenameFormat);
if (isSafMode) { if (isSafMode) {
safFileName = await _buildSafFileNameForItem( safFileName = await _buildSafFileNameForItem(
item, item,
@@ -775,6 +780,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
label: extendedMetadata?.label, label: extendedMetadata?.label,
copyright: extendedMetadata?.copyright, copyright: extendedMetadata?.copyright,
stageSafForDeferredPublish: isSafMode, stageSafForDeferredPublish: isSafMode,
qualityVariantCollisionOnly: qualityVariantCollisionOnly,
).withStrategy(useExtensions: true, useFallback: state.autoFallback); ).withStrategy(useExtensions: true, useFallback: state.autoFallback);
return _NativeWorkerRequestContext( return _NativeWorkerRequestContext(
@@ -787,6 +793,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
downloadTreeUri: isSafMode ? settings.downloadTreeUri : null, downloadTreeUri: isSafMode ? settings.downloadTreeUri : null,
safRelativeDir: isSafMode ? outputDir : null, safRelativeDir: isSafMode ? outputDir : null,
safFileName: safFileName, safFileName: safFileName,
qualityVariantCollisionOnly: qualityVariantCollisionOnly,
); );
} }
@@ -1177,6 +1184,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
downloadTreeUri: context.downloadTreeUri, downloadTreeUri: context.downloadTreeUri,
safRelativeDir: context.safRelativeDir, safRelativeDir: context.safRelativeDir,
fileName: result['file_name'] as String? ?? context.safFileName, fileName: result['file_name'] as String? ?? context.safFileName,
collisionOnly: context.qualityVariantCollisionOnly,
); );
filePath = variantOutcome.filePath; filePath = variantOutcome.filePath;
actualBitDepth = readPositiveInt(result['actual_bit_depth']); actualBitDepth = readPositiveInt(result['actual_bit_depth']);
@@ -100,6 +100,7 @@ class _DownloadRun {
String effectiveOutputDir = ''; String effectiveOutputDir = '';
String? appOutputDir; String? appOutputDir;
String? safFileName; String? safFileName;
bool qualityVariantCollisionOnly = false;
String? safBaseName; String? safBaseName;
String? finalSafFileName; String? finalSafFileName;
@@ -419,6 +420,9 @@ class _DownloadRun {
item, item,
baseFilenameFormat, baseFilenameFormat,
); );
qualityVariantCollisionOnly =
item.preserveQualityVariant &&
!_explicitQualityFilenameTokenPattern.hasMatch(baseFilenameFormat);
if (isSafMode) { if (isSafMode) {
final builtSafFileName = await n._buildSafFileNameForItem( final builtSafFileName = await n._buildSafFileNameForItem(
item, item,
@@ -549,6 +553,7 @@ class _DownloadRun {
genre: genre, genre: genre,
label: label, label: label,
copyright: copyright, copyright: copyright,
qualityVariantCollisionOnly: qualityVariantCollisionOnly,
); );
return PlatformBridge.downloadByStrategy( return PlatformBridge.downloadByStrategy(
@@ -705,6 +710,7 @@ class _DownloadRun {
downloadTreeUri: settings.downloadTreeUri, downloadTreeUri: settings.downloadTreeUri,
safRelativeDir: effectiveOutputDir, safRelativeDir: effectiveOutputDir,
fileName: finalSafFileName ?? safFileName, fileName: finalSafFileName ?? safFileName,
collisionOnly: qualityVariantCollisionOnly,
); );
filePath = variantOutcome.filePath; filePath = variantOutcome.filePath;
if (variantOutcome.fileName != null) { if (variantOutcome.fileName != null) {
@@ -50,6 +50,7 @@ class DownloadRequestPayload {
final bool requiresContainerConversion; final bool requiresContainerConversion;
final bool allowQualityVariant; final bool allowQualityVariant;
final String qualityVariant; final String qualityVariant;
final bool qualityVariantCollisionOnly;
final String songLinkRegion; final String songLinkRegion;
const DownloadRequestPayload({ const DownloadRequestPayload({
@@ -102,6 +103,7 @@ class DownloadRequestPayload {
this.requiresContainerConversion = false, this.requiresContainerConversion = false,
this.allowQualityVariant = false, this.allowQualityVariant = false,
this.qualityVariant = '', this.qualityVariant = '',
this.qualityVariantCollisionOnly = false,
this.songLinkRegion = 'US', this.songLinkRegion = 'US',
}); });
@@ -156,6 +158,7 @@ class DownloadRequestPayload {
'requires_container_conversion': requiresContainerConversion, 'requires_container_conversion': requiresContainerConversion,
'allow_quality_variant': allowQualityVariant, 'allow_quality_variant': allowQualityVariant,
'quality_variant': qualityVariant, 'quality_variant': qualityVariant,
'quality_variant_collision_only': qualityVariantCollisionOnly,
'songlink_region': songLinkRegion, 'songlink_region': songLinkRegion,
}; };
} }
@@ -214,6 +217,7 @@ class DownloadRequestPayload {
requiresContainerConversion: requiresContainerConversion, requiresContainerConversion: requiresContainerConversion,
allowQualityVariant: allowQualityVariant, allowQualityVariant: allowQualityVariant,
qualityVariant: qualityVariant, qualityVariant: qualityVariant,
qualityVariantCollisionOnly: qualityVariantCollisionOnly,
songLinkRegion: songLinkRegion, songLinkRegion: songLinkRegion,
); );
} }
+28 -15
View File
@@ -678,6 +678,26 @@ class PlatformBridge {
}); });
} }
static Future<Map<String, dynamic>> 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<void> openContentUri(String uri, {String mimeType = ''}) async { static Future<void> openContentUri(String uri, {String mimeType = ''}) async {
await _channel.invokeMethod('openContentUri', { await _channel.invokeMethod('openContentUri', {
'uri': uri, 'uri': uri,
@@ -1069,9 +1089,10 @@ class PlatformBridge {
static Future<Map<String, dynamic>> getNativeDownloadWorkerSnapshot({ static Future<Map<String, dynamic>> getNativeDownloadWorkerSnapshot({
int sinceStateSerial = 0, int sinceStateSerial = 0,
}) async { }) async {
final result = await _channel.invokeMethod('getNativeDownloadWorkerSnapshot', { final result = await _channel.invokeMethod(
'since_state_serial': sinceStateSerial, 'getNativeDownloadWorkerSnapshot',
}); {'since_state_serial': sinceStateSerial},
);
return _decodeMapResult(result); return _decodeMapResult(result);
} }
@@ -1212,9 +1233,7 @@ class PlatformBridge {
}); });
} }
static Future<Map<String, dynamic>> loadExtensionsFromDir( static Future<Map<String, dynamic>> loadExtensionsFromDir(String dirPath) {
String dirPath,
) {
_log.d('loadExtensionsFromDir: $dirPath'); _log.d('loadExtensionsFromDir: $dirPath');
return _invokeMap('loadExtensionsFromDir', {'dir_path': dirPath}); return _invokeMap('loadExtensionsFromDir', {'dir_path': dirPath});
} }
@@ -1249,9 +1268,7 @@ class PlatformBridge {
return _invokeMap('upgradeExtension', {'file_path': filePath}); return _invokeMap('upgradeExtension', {'file_path': filePath});
} }
static Future<Map<String, dynamic>> checkExtensionUpgrade( static Future<Map<String, dynamic>> checkExtensionUpgrade(String filePath) {
String filePath,
) {
_log.d('checkExtensionUpgrade: $filePath'); _log.d('checkExtensionUpgrade: $filePath');
return _invokeMap('checkExtensionUpgrade', {'file_path': filePath}); return _invokeMap('checkExtensionUpgrade', {'file_path': filePath});
} }
@@ -1311,15 +1328,11 @@ class PlatformBridge {
return _decodeStringListResult(result, 'getMetadataProviderPriority'); return _decodeStringListResult(result, 'getMetadataProviderPriority');
} }
static Future<Map<String, dynamic>> getExtensionSettings( static Future<Map<String, dynamic>> getExtensionSettings(String extensionId) {
String extensionId,
) {
return _invokeMap('getExtensionSettings', {'extension_id': extensionId}); return _invokeMap('getExtensionSettings', {'extension_id': extensionId});
} }
static Future<Map<String, dynamic>> checkExtensionHealth( static Future<Map<String, dynamic>> checkExtensionHealth(String extensionId) {
String extensionId,
) {
return _invokeMap('checkExtensionHealth', {'extension_id': extensionId}); return _invokeMap('checkExtensionHealth', {'extension_id': extensionId});
} }
+38
View File
@@ -222,6 +222,44 @@ String applyQualityVariantFilenameLabel({
return '$stem - $qualityLabel$extension'; 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) { String lossyFormatForSetting(String value) {
final normalized = value.trim().toLowerCase(); final normalized = value.trim().toLowerCase();
if (normalized.startsWith('opus')) return 'opus'; if (normalized.startsWith('opus')) return 'opus';
+38
View File
@@ -333,6 +333,38 @@ void main() {
'Post Processed Song - 16bit-44.1kHz.flac', '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', () { group('Library collections', () {
@@ -783,6 +815,7 @@ void main() {
outputExt: '.flac', outputExt: '.flac',
allowQualityVariant: true, allowQualityVariant: true,
qualityVariant: 'qv_12345678', qualityVariant: 'qv_12345678',
qualityVariantCollisionOnly: true,
songLinkRegion: 'ID', songLinkRegion: 'ID',
); );
@@ -836,6 +869,7 @@ void main() {
'requires_container_conversion': false, 'requires_container_conversion': false,
'allow_quality_variant': true, 'allow_quality_variant': true,
'quality_variant': 'qv_12345678', 'quality_variant': 'qv_12345678',
'quality_variant_collision_only': true,
'songlink_region': 'ID', 'songlink_region': 'ID',
}); });
}); });
@@ -859,6 +893,10 @@ void main() {
expect(updated.filenameFormat, payload.filenameFormat); expect(updated.filenameFormat, payload.filenameFormat);
expect(updated.allowQualityVariant, payload.allowQualityVariant); expect(updated.allowQualityVariant, payload.allowQualityVariant);
expect(updated.qualityVariant, payload.qualityVariant); expect(updated.qualityVariant, payload.qualityVariant);
expect(
updated.qualityVariantCollisionOnly,
payload.qualityVariantCollisionOnly,
);
}); });
}); });