mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
fix(download): suffix only colliding quality variants
This commit is contained in:
@@ -1052,6 +1052,38 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
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" -> {
|
||||
val uriStr = call.argument<String>("uri") ?: ""
|
||||
val mimeType = call.argument<String>("mime_type") ?: ""
|
||||
|
||||
@@ -44,6 +44,7 @@ object NativeDownloadFinalizer {
|
||||
internal val nativeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
internal val activeFFmpegSessionLock = Any()
|
||||
internal val ffmpegCompleteCallbackLock = Any()
|
||||
internal val qualityVariantNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
internal var forwardedFFmpegCompleteCallback: FFmpegSessionCompleteCallback? = null
|
||||
internal val nativeFilteringFFmpegCompleteCallback = FFmpegSessionCompleteCallback { session ->
|
||||
val isNativeSession = synchronized(activeFFmpegSessionLock) {
|
||||
@@ -257,6 +258,7 @@ object NativeDownloadFinalizer {
|
||||
.optBoolean("allow_quality_variant", false)
|
||||
val history = if (
|
||||
saveDownloadHistory &&
|
||||
!result.optBoolean("publish_collision_existing", false) &&
|
||||
!(preserveQualityVariant && result.optBoolean("already_exists", false))
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -157,6 +157,36 @@ internal object NativeFinalizationPolicy {
|
||||
return "$stem - $qualityLabel$extension"
|
||||
}
|
||||
|
||||
fun removeQualityVariantStagingLabel(
|
||||
fileName: String,
|
||||
stagingLabel: String,
|
||||
): String {
|
||||
if (stagingLabel.isEmpty() || !fileName.contains(stagingLabel)) return fileName
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val cleanedStem = stem
|
||||
.replace(stagingLabel, "")
|
||||
.replace(Regex("[\\s_-]+$"), "")
|
||||
.trim()
|
||||
.ifBlank { "track" }
|
||||
return "$cleanedStem$extension"
|
||||
}
|
||||
|
||||
fun resolveQualityVariantFilename(
|
||||
fileName: String,
|
||||
stagingLabel: String,
|
||||
qualityLabel: String,
|
||||
collisionOnly: Boolean,
|
||||
cleanNameExists: Boolean,
|
||||
): String {
|
||||
if (!collisionOnly || cleanNameExists) {
|
||||
return applyQualityVariantFilenameLabel(fileName, stagingLabel, qualityLabel)
|
||||
}
|
||||
return removeQualityVariantStagingLabel(fileName, stagingLabel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user-facing name that a deferred SAF download was assigned
|
||||
* before its audio was materialized in the app cache. Container and
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.logicalOutputFileName
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolveQualityVariantFilename
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
||||
import gobackend.Gobackend
|
||||
@@ -68,11 +70,14 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
|
||||
requestSafFileName = input.request.optString("saf_file_name", ""),
|
||||
currentFileName = state.fileName,
|
||||
)
|
||||
val preferredName = applyQualityVariantFilenameLabel(
|
||||
val variantName = applyQualityVariantFilenameLabel(
|
||||
fileName = logicalFileName,
|
||||
stagingLabel = stagingLabel,
|
||||
qualityLabel = qualityLabel,
|
||||
)
|
||||
val cleanName = removeQualityVariantStagingLabel(logicalFileName, stagingLabel)
|
||||
val collisionOnly = input.request.optBoolean("quality_variant_collision_only", false)
|
||||
val preferredName = variantName
|
||||
if (preferredName == logicalFileName && preferredName == state.fileName) return
|
||||
input.result.put("quality_variant_file_name", preferredName)
|
||||
if (isDeferredSafPublish(input)) {
|
||||
@@ -83,15 +88,30 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
|
||||
if (state.filePath.startsWith("content://")) {
|
||||
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
|
||||
try {
|
||||
val writeResult = SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = input.request.optString("saf_tree_uri", ""),
|
||||
relativeDir = input.request.optString("saf_relative_dir", ""),
|
||||
fileName = preferredName,
|
||||
mimeType = mimeTypeForExt(File(preferredName).extension),
|
||||
srcPath = tempPath,
|
||||
preservedSuffix = qualityLabel,
|
||||
) ?: return
|
||||
val treeUri = input.request.optString("saf_tree_uri", "")
|
||||
val relativeDir = input.request.optString("saf_relative_dir", "")
|
||||
val writeResult = if (collisionOnly) {
|
||||
SafDownloadHandler.writeFileToSafCollisionAware(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
cleanFileName = cleanName,
|
||||
variantFileName = variantName,
|
||||
mimeType = mimeTypeForExt(File(variantName).extension),
|
||||
srcPath = tempPath,
|
||||
preservedSuffix = qualityLabel,
|
||||
)
|
||||
} else {
|
||||
SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = preferredName,
|
||||
mimeType = mimeTypeForExt(File(preferredName).extension),
|
||||
srcPath = tempPath,
|
||||
preservedSuffix = qualityLabel,
|
||||
)
|
||||
} ?: return
|
||||
SafDownloadHandler.deleteContentUri(context, state.filePath)
|
||||
state.filePath = writeResult.uri
|
||||
state.fileName = writeResult.fileName
|
||||
@@ -100,13 +120,32 @@ internal fun NativeDownloadFinalizer.finalizeQualityVariantFilename(
|
||||
}
|
||||
} else {
|
||||
val source = File(state.filePath)
|
||||
val target = uniqueLocalFile(source.parentFile, preferredName)
|
||||
if (!source.renameTo(target)) {
|
||||
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
|
||||
return
|
||||
val cleanTarget = File(source.parentFile, cleanName)
|
||||
val lockTarget = if (collisionOnly) cleanTarget else File(source.parentFile, preferredName)
|
||||
val lockKey = lockTarget.absolutePath.lowercase(Locale.ROOT)
|
||||
val lock = qualityVariantNameLocks.computeIfAbsent(lockKey) { Any() }
|
||||
synchronized(lock) {
|
||||
val selectedName = if (collisionOnly) {
|
||||
resolveQualityVariantFilename(
|
||||
fileName = logicalFileName,
|
||||
stagingLabel = stagingLabel,
|
||||
qualityLabel = qualityLabel,
|
||||
collisionOnly = true,
|
||||
cleanNameExists = cleanTarget.absolutePath != source.absolutePath && cleanTarget.exists(),
|
||||
)
|
||||
} else {
|
||||
preferredName
|
||||
}
|
||||
input.result.put("quality_variant_file_name", selectedName)
|
||||
if (selectedName == state.fileName) return@synchronized
|
||||
val target = uniqueLocalFile(source.parentFile, selectedName)
|
||||
if (!source.renameTo(target)) {
|
||||
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
|
||||
return@synchronized
|
||||
}
|
||||
state.filePath = target.absolutePath
|
||||
state.fileName = target.name
|
||||
}
|
||||
state.filePath = target.absolutePath
|
||||
state.fileName = target.name
|
||||
}
|
||||
|
||||
input.result.put("file_path", state.filePath)
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.zarz.spotiflac.NativeFinalizationPolicy.formatIndexTag
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLosslessAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.isLossyAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.normalizeAudioCodec
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.removeQualityVariantStagingLabel
|
||||
import com.zarz.spotiflac.NativeFinalizationPolicy.resolvePreferredDecryptionExtension
|
||||
import gobackend.Gobackend
|
||||
import org.json.JSONObject
|
||||
@@ -85,32 +86,54 @@ internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
|
||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
||||
val mimeType = mimeTypeForExt(outputFile.extension)
|
||||
val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
|
||||
val uniqueWrite = if (preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(),
|
||||
)
|
||||
val qualityLabel = qualityVariantFilenameLabel(state).orEmpty()
|
||||
val collisionOnly = preserveQualityVariant &&
|
||||
input.request.optBoolean("quality_variant_collision_only", false)
|
||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
||||
val logicalVariantName = input.request.optString("saf_file_name", "")
|
||||
.ifBlank { finalName }
|
||||
val cleanName = removeQualityVariantStagingLabel(logicalVariantName, stagingLabel)
|
||||
val variantName = if (qualityLabel.isNotEmpty()) {
|
||||
applyQualityVariantFilenameLabel(logicalVariantName, stagingLabel, qualityLabel)
|
||||
} else {
|
||||
null
|
||||
finalName
|
||||
}
|
||||
val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSaf(
|
||||
|
||||
var alreadyExists = false
|
||||
val published = when {
|
||||
collisionOnly -> SafDownloadHandler.writeFileToSafCollisionAware(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
cleanFileName = cleanName,
|
||||
variantFileName = variantName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
preservedSuffix = qualityLabel,
|
||||
)
|
||||
preserveQualityVariant -> SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
preservedSuffix = qualityLabel,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
else -> SafDownloadHandler.writeFileToSafIfAbsent(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
)?.let { result ->
|
||||
alreadyExists = result.alreadyExists
|
||||
SafDownloadHandler.UniqueWriteResult(result.uri, result.fileName)
|
||||
}
|
||||
} ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
val publishedName = uniqueWrite?.fileName ?: finalName
|
||||
val newUri = published.uri
|
||||
val publishedName = published.fileName
|
||||
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
|
||||
outputFile.delete()
|
||||
@@ -118,6 +141,11 @@ internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
|
||||
state.fileName = publishedName
|
||||
input.result.put("file_path", newUri)
|
||||
input.result.put("file_name", publishedName)
|
||||
if (alreadyExists) {
|
||||
input.result.put("already_exists", true)
|
||||
input.result.put("message", "File already exists")
|
||||
input.result.put("publish_collision_existing", true)
|
||||
}
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", newUri)
|
||||
replayGain.put("file_name", publishedName)
|
||||
|
||||
@@ -27,6 +27,11 @@ object SafDownloadHandler {
|
||||
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
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(
|
||||
treeUriStr: String,
|
||||
@@ -352,6 +357,79 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafCollisionAware(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
cleanFileName: String,
|
||||
variantFileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
preservedSuffix: String = "",
|
||||
): UniqueWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val cleanName = sanitizeFilename(cleanFileName)
|
||||
val preferredVariant = sanitizeFilenamePreservingSuffix(
|
||||
variantFileName,
|
||||
preservedSuffix,
|
||||
)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, cleanName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
||||
?: return@withSafNameLock null
|
||||
val selectedName = if (targetDir.findFile(cleanName) == null) {
|
||||
cleanName
|
||||
} else {
|
||||
findAvailableFileName(targetDir, preferredVariant, preservedSuffix)
|
||||
}
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
selectedName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
UniqueWriteResult(uri = uri, fileName = selectedName)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafIfAbsent(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
): ExistingAwareWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, finalName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
|
||||
?: return@withSafNameLock null
|
||||
val existing = targetDir.findFile(finalName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0L) {
|
||||
return@withSafNameLock ExistingAwareWriteResult(
|
||||
uri = existing.uri.toString(),
|
||||
fileName = existing.name ?: finalName,
|
||||
alreadyExists = true,
|
||||
)
|
||||
}
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
finalName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
ExistingAwareWriteResult(
|
||||
uri = uri,
|
||||
fileName = finalName,
|
||||
alreadyExists = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
|
||||
val sanitized = sanitizeFilename(fileName)
|
||||
val trimmedSuffix = suffix.trim()
|
||||
|
||||
@@ -150,6 +150,38 @@ class NativeFinalizationPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun measuredQualityIsAddedOnlyAfterCleanNameCollision() {
|
||||
val stagedName = "Artist - Track - qv_ab12cd34.flac"
|
||||
assertEquals(
|
||||
"Artist - Track.flac",
|
||||
NativeFinalizationPolicy.removeQualityVariantStagingLabel(
|
||||
fileName = stagedName,
|
||||
stagingLabel = "qv_ab12cd34",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Artist - Track.flac",
|
||||
NativeFinalizationPolicy.resolveQualityVariantFilename(
|
||||
fileName = stagedName,
|
||||
stagingLabel = "qv_ab12cd34",
|
||||
qualityLabel = "24bit-96kHz",
|
||||
collisionOnly = true,
|
||||
cleanNameExists = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Artist - Track - 24bit-96kHz.flac",
|
||||
NativeFinalizationPolicy.resolveQualityVariantFilename(
|
||||
fileName = stagedName,
|
||||
stagingLabel = "qv_ab12cd34",
|
||||
qualityLabel = "24bit-96kHz",
|
||||
collisionOnly = true,
|
||||
cleanNameExists = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deferredSafNamingNeverPublishesTheNativeCacheName() {
|
||||
val logicalName = NativeFinalizationPolicy.logicalOutputFileName(
|
||||
|
||||
Reference in New Issue
Block a user