mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-05 18:48:38 +02:00
fix(download): suffix only colliding quality variants
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -3968,7 +3968,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadQualityVariantsDescription =>
|
String get downloadQualityVariantsDescription =>
|
||||||
'선택된 음질을 파일 이름에 추가하고, 각 버전을 다운로드 기록에 유지합니다';
|
'각 음질 버전을 유지하고, 같은 이름이 이미 사용 중일 때만 측정된 음질을 파일 이름에 추가합니다';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackOptionDownloadQualityVariant => '다른 음질 다운로드';
|
String get trackOptionDownloadQualityVariant => '다른 음질 다운로드';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user