mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
fix(download): name variants by measured quality
This commit is contained in:
@@ -2458,6 +2458,32 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(createdUri)
|
||||
}
|
||||
"safCreateUniqueFromPath" -> {
|
||||
val treeUriStr = call.argument<String>("tree_uri") ?: ""
|
||||
val relativeDir = call.argument<String>("relative_dir") ?: ""
|
||||
val fileName = call.argument<String>("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() || fileName.isBlank()) return@withContext null
|
||||
SafDownloadHandler.writeFileToSafUnique(
|
||||
context = this@MainActivity,
|
||||
treeUriStr = treeUriStr,
|
||||
relativeDir = relativeDir,
|
||||
fileName = fileName,
|
||||
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") ?: ""
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object NativeDownloadFinalizer {
|
||||
private const val TAG = "NativeFinalizer"
|
||||
@@ -204,24 +205,30 @@ object NativeDownloadFinalizer {
|
||||
checkCancelled(shouldCancel)
|
||||
finalizeMetadata(context, effectiveInput, state)
|
||||
checkCancelled(shouldCancel)
|
||||
runPostProcessing(context, effectiveInput, state, shouldCancel)
|
||||
checkCancelled(shouldCancel)
|
||||
val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel)
|
||||
if (replayGain != null) result.put("replaygain", replayGain)
|
||||
checkCancelled(shouldCancel)
|
||||
try {
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Quality metadata refresh failed (non-fatal): ${e.message}")
|
||||
}
|
||||
qualityMetadataRefreshed = true
|
||||
try {
|
||||
finalizeQualityVariantFilename(context, effectiveInput, state)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Quality variant rename failed (non-fatal): ${e.message}")
|
||||
}
|
||||
checkCancelled(shouldCancel)
|
||||
try {
|
||||
writeExternalLrc(context, effectiveInput, state)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "External LRC write failed (non-fatal): ${e.message}")
|
||||
}
|
||||
checkCancelled(shouldCancel)
|
||||
runPostProcessing(context, effectiveInput, state, shouldCancel)
|
||||
checkCancelled(shouldCancel)
|
||||
val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel)
|
||||
if (replayGain != null) result.put("replaygain", replayGain)
|
||||
checkCancelled(shouldCancel)
|
||||
if (isDeferredSafPublish(effectiveInput)) {
|
||||
try {
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Quality metadata refresh failed (non-fatal): ${e.message}")
|
||||
}
|
||||
qualityMetadataRefreshed = true
|
||||
publishDeferredSafOutput(context, effectiveInput, state)
|
||||
} else {
|
||||
promoteStagedSafOutputIfNeeded(context, effectiveInput, state)
|
||||
@@ -919,6 +926,129 @@ object NativeDownloadFinalizer {
|
||||
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
|
||||
}
|
||||
|
||||
private fun qualityVariantFilenameLabel(state: FinalizeState): String? {
|
||||
val measuredQuality = state.quality
|
||||
if (isLossyAudioCodec(state.audioCodec)) {
|
||||
val bitrate = state.bitrateKbps ?: Regex(
|
||||
"\\b(\\d+)\\s*kbps\\b",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(measuredQuality)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
return bitrate?.takeIf { it >= 16 }?.let { "${it}kbps" }
|
||||
}
|
||||
|
||||
var bitDepth = state.bitDepth
|
||||
var sampleRate = state.sampleRate
|
||||
if (bitDepth == null || sampleRate == null) {
|
||||
val match = Regex(
|
||||
"\\b(\\d+)\\s*(?:-|\\s)?bit\\s*[/_-]\\s*(\\d+(?:\\.\\d+)?)\\s*k?hz\\b",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(measuredQuality)
|
||||
bitDepth = bitDepth ?: match?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
sampleRate = sampleRate ?: match?.groupValues?.getOrNull(2)?.toDoubleOrNull()?.let { rate ->
|
||||
if (rate < 1000) (rate * 1000).roundToInt() else rate.roundToInt()
|
||||
}
|
||||
}
|
||||
if (bitDepth == null || bitDepth <= 0 || sampleRate == null || sampleRate <= 0) return null
|
||||
val khz = sampleRate / 1000.0
|
||||
val precision = if (sampleRate % 1000 == 0) 0 else 1
|
||||
val sampleRateLabel = "%.${precision}f".format(Locale.US, khz)
|
||||
return "${bitDepth}bit-${sampleRateLabel}kHz"
|
||||
}
|
||||
|
||||
private fun finalizeQualityVariantFilename(
|
||||
context: Context,
|
||||
input: FinalizeInput,
|
||||
state: FinalizeState,
|
||||
) {
|
||||
if (!input.request.optBoolean("allow_quality_variant", false)) return
|
||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
||||
val qualityLabel = qualityVariantFilenameLabel(state)
|
||||
if (qualityLabel == null) {
|
||||
Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
val preferredName = applyQualityVariantFilenameLabel(
|
||||
fileName = state.fileName,
|
||||
stagingLabel = stagingLabel,
|
||||
qualityLabel = qualityLabel,
|
||||
)
|
||||
if (preferredName == state.fileName) return
|
||||
input.result.put("quality_variant_file_name", preferredName)
|
||||
if (isDeferredSafPublish(input)) {
|
||||
state.fileName = preferredName
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
SafDownloadHandler.deleteContentUri(context, state.filePath)
|
||||
state.filePath = writeResult.uri
|
||||
state.fileName = writeResult.fileName
|
||||
} finally {
|
||||
File(tempPath).delete()
|
||||
}
|
||||
} 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
|
||||
}
|
||||
state.filePath = target.absolutePath
|
||||
state.fileName = target.name
|
||||
}
|
||||
|
||||
input.result.put("file_path", state.filePath)
|
||||
input.result.put("file_name", state.fileName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", state.filePath)
|
||||
replayGain.put("file_name", state.fileName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyQualityVariantFilenameLabel(
|
||||
fileName: String,
|
||||
stagingLabel: String,
|
||||
qualityLabel: String,
|
||||
): String {
|
||||
if (stagingLabel.isNotEmpty() && fileName.contains(stagingLabel)) {
|
||||
return fileName.replace(stagingLabel, qualityLabel)
|
||||
}
|
||||
if (fileName.contains(qualityLabel)) 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 ""
|
||||
return "$stem - $qualityLabel$extension"
|
||||
}
|
||||
|
||||
private fun uniqueLocalFile(parent: File?, preferredName: String): File {
|
||||
val directory = parent ?: return File(preferredName)
|
||||
var candidate = File(directory, preferredName)
|
||||
if (!candidate.exists()) return candidate
|
||||
val dotIndex = preferredName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName
|
||||
val extension = if (hasExtension) preferredName.substring(dotIndex) else ""
|
||||
var counter = 2
|
||||
while (candidate.exists()) {
|
||||
candidate = File(directory, "$stem ($counter)$extension")
|
||||
counter++
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private fun audioFormatForCodec(codec: String?): String? {
|
||||
return when (normalizeAudioCodec(codec)) {
|
||||
"flac" -> "FLAC"
|
||||
@@ -1620,7 +1750,8 @@ object NativeDownloadFinalizer {
|
||||
|
||||
private fun desiredFileName(input: FinalizeInput, state: FinalizeState, extension: String): String {
|
||||
val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } }
|
||||
val rawName = input.request.optString("saf_file_name", "")
|
||||
val rawName = input.result.optString("quality_variant_file_name", "")
|
||||
.ifBlank { input.request.optString("saf_file_name", "") }
|
||||
.ifBlank { state.fileName }
|
||||
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
|
||||
@@ -1770,24 +1901,46 @@ object NativeDownloadFinalizer {
|
||||
val relativeDir = input.result.optString("saf_relative_dir", "")
|
||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
||||
val mimeType = mimeTypeForExt(outputFile.extension)
|
||||
val newUri = SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
) ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
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(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
} ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
val publishedName = uniqueWrite?.fileName ?: finalName
|
||||
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$finalName bytes=${outputFile.length()}")
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
|
||||
outputFile.delete()
|
||||
state.filePath = newUri
|
||||
state.fileName = finalName
|
||||
state.fileName = publishedName
|
||||
input.result.put("file_path", newUri)
|
||||
input.result.put("file_name", finalName)
|
||||
input.result.put("file_name", publishedName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", newUri)
|
||||
replayGain.put("file_name", finalName)
|
||||
replayGain.put("file_name", publishedName)
|
||||
}
|
||||
if (state.pendingExternalLrc != null) {
|
||||
state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc"
|
||||
}
|
||||
input.result.put("saf_deferred_published", true)
|
||||
publishPendingDeferredExternalLrc(context, input, state)
|
||||
|
||||
@@ -26,6 +26,8 @@ object SafDownloadHandler {
|
||||
// the exists check and reports already_exists.
|
||||
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
data class UniqueWriteResult(val uri: String, val fileName: String)
|
||||
|
||||
private fun <T> withSafNameLock(
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
@@ -320,6 +322,106 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafUnique(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
preservedSuffix: String = "",
|
||||
): UniqueWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
|
||||
val availableName = findAvailableFileName(
|
||||
targetDir,
|
||||
preferredName,
|
||||
preservedSuffix,
|
||||
)
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
availableName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
UniqueWriteResult(uri = uri, fileName = availableName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
|
||||
val sanitized = sanitizeFilename(fileName)
|
||||
val trimmedSuffix = suffix.trim()
|
||||
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
|
||||
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
|
||||
val safeSuffix = sanitizeFilename(trimmedSuffix)
|
||||
val reserved = " - $safeSuffix$extension"
|
||||
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
|
||||
.coerceAtLeast(1)
|
||||
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
|
||||
.trim()
|
||||
.trim('.', ' ', '_', '-')
|
||||
.ifBlank { "track" }
|
||||
return "$safePrefix$reserved"
|
||||
}
|
||||
|
||||
private fun findAvailableFileName(
|
||||
parent: DocumentFile,
|
||||
preferredName: String,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
if (parent.findFile(preferredName) == null) return preferredName
|
||||
for (counter in 2..9999) {
|
||||
val candidate = appendFilenameCounter(
|
||||
preferredName,
|
||||
counter.toLong(),
|
||||
preservedSuffix,
|
||||
)
|
||||
if (parent.findFile(candidate) == null) return candidate
|
||||
}
|
||||
return appendFilenameCounter(
|
||||
preferredName,
|
||||
System.currentTimeMillis(),
|
||||
preservedSuffix,
|
||||
)
|
||||
}
|
||||
|
||||
private fun appendFilenameCounter(
|
||||
fileName: String,
|
||||
counter: Long,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val safePreservedSuffix = preservedSuffix.trim()
|
||||
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
|
||||
val stem = if (hasPreservedSuffix) {
|
||||
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
|
||||
} else {
|
||||
originalStem
|
||||
}
|
||||
val suffix = if (hasPreservedSuffix) {
|
||||
" - $safePreservedSuffix ($counter)"
|
||||
} else {
|
||||
" ($counter)"
|
||||
}
|
||||
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
|
||||
suffix.toByteArray(Charsets.UTF_8).size
|
||||
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
|
||||
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
|
||||
return "$safeStem$suffix$extension"
|
||||
}
|
||||
|
||||
private fun writeFileToSafLocked(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
|
||||
Reference in New Issue
Block a user