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,
|
||||
|
||||
@@ -51,6 +51,7 @@ type DownloadRequest struct {
|
||||
UseFallback bool `json:"use_fallback,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
AllowQualityVariant bool `json:"allow_quality_variant,omitempty"`
|
||||
QualityVariant string `json:"quality_variant,omitempty"`
|
||||
SongLinkRegion string `json:"songlink_region,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -890,13 +890,14 @@ func buildDownloadFilename(req DownloadRequest) string {
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
"quality": req.Quality,
|
||||
"quality_variant": req.QualityVariant,
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
filename = sanitizeFilenamePreservingToken(filename, req.QualityVariant)
|
||||
|
||||
ext := strings.TrimSpace(req.OutputExt)
|
||||
if ext == "" {
|
||||
|
||||
+28
-2
@@ -18,6 +18,8 @@ var (
|
||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||
)
|
||||
|
||||
const maxSanitizedFilenameBytes = 200
|
||||
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := strings.ReplaceAll(filename, "/", " ")
|
||||
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
|
||||
@@ -47,8 +49,8 @@ func sanitizeFilename(filename string) string {
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = truncateUTF8Bytes(sanitized, 200)
|
||||
if len(sanitized) > maxSanitizedFilenameBytes {
|
||||
sanitized = truncateUTF8Bytes(sanitized, maxSanitizedFilenameBytes)
|
||||
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
}
|
||||
@@ -60,6 +62,29 @@ func sanitizeFilename(filename string) string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func sanitizeFilenamePreservingToken(filename string, token string) string {
|
||||
sanitized := sanitizeFilename(filename)
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || !strings.Contains(filename, token) || strings.Contains(sanitized, token) {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
safeToken := sanitizeFilename(token)
|
||||
suffix := " - " + safeToken
|
||||
prefixLimit := maxSanitizedFilenameBytes - len(suffix)
|
||||
if prefixLimit <= 0 {
|
||||
return truncateUTF8Bytes(safeToken, maxSanitizedFilenameBytes)
|
||||
}
|
||||
rawPrefix := strings.Trim(strings.ReplaceAll(filename, token, ""), " _-")
|
||||
prefix := sanitizeFilename(rawPrefix)
|
||||
prefix = truncateUTF8Bytes(prefix, prefixLimit)
|
||||
prefix = strings.TrimSpace(strings.Trim(prefix, ". _-"))
|
||||
if prefix == "" || prefix == "Unknown" {
|
||||
return safeToken
|
||||
}
|
||||
return prefix + suffix
|
||||
}
|
||||
|
||||
func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||
if maxBytes <= 0 || len(value) <= maxBytes {
|
||||
return value
|
||||
@@ -109,6 +134,7 @@ func buildFilenameFromTemplate(template string, metadata map[string]any) string
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
||||
"{quality}": getString(metadata, "quality"),
|
||||
"{quality_variant}": getString(metadata, "quality_variant"),
|
||||
}
|
||||
|
||||
for placeholder, value := range placeholders {
|
||||
|
||||
@@ -88,6 +88,22 @@ func TestBuildFilenameFromTemplate_QualityVariant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_QualityVariantStagingToken(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"quality_variant": "qv_12345678",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {title} - {quality_variant}",
|
||||
metadata,
|
||||
)
|
||||
if formatted != "Artist Name - Song Name - qv_12345678" {
|
||||
t.Fatalf("unexpected quality variant filename: %q", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadFilename_ProvidesRequestedQuality(t *testing.T) {
|
||||
filename := buildDownloadFilename(DownloadRequest{
|
||||
TrackName: "Song Name",
|
||||
@@ -102,6 +118,23 @@ func TestBuildDownloadFilename_ProvidesRequestedQuality(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadFilename_PreservesVariantTokenWhenTruncated(t *testing.T) {
|
||||
filename := buildDownloadFilename(DownloadRequest{
|
||||
TrackName: strings.Repeat("Very Long Song ", 30),
|
||||
ArtistName: "Artist Name",
|
||||
FilenameFormat: "{artist} - {title} - {quality_variant}",
|
||||
QualityVariant: "qv_12345678",
|
||||
OutputExt: ".flac",
|
||||
})
|
||||
|
||||
if !strings.Contains(filename, "qv_12345678") {
|
||||
t.Fatalf("quality variant token was truncated: %q", filename)
|
||||
}
|
||||
if len(strings.TrimSuffix(filename, ".flac")) > maxSanitizedFilenameBytes {
|
||||
t.Fatalf("filename base exceeds limit: %d bytes", len(filename))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
"artist": "Artist Name",
|
||||
|
||||
@@ -69,7 +69,7 @@ final _batchUniqueFilenameTokenPattern = RegExp(
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _qualityFilenameTokenPattern = RegExp(
|
||||
r'\{quality\}',
|
||||
r'\{quality_variant\}',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
@@ -1143,6 +1143,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
outputExt,
|
||||
),
|
||||
allowQualityVariant: item.preserveQualityVariant,
|
||||
qualityVariant: item.preserveQualityVariant
|
||||
? qualityVariantStagingLabel(item.id)
|
||||
: '',
|
||||
songLinkRegion: settings.songLinkRegion,
|
||||
);
|
||||
}
|
||||
@@ -2549,10 +2552,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_filenameMetadataForTrack(
|
||||
trackToDownload,
|
||||
quality: quality,
|
||||
qualityVariant: item.preserveQualityVariant
|
||||
? qualityVariantStagingLabel(item.id)
|
||||
: '',
|
||||
playlistPosition: _validPlaylistPosition(item),
|
||||
),
|
||||
);
|
||||
safFileName = await _buildSafFileName(baseName, safOutputExt);
|
||||
safFileName = await _buildSafFileName(
|
||||
baseName,
|
||||
safOutputExt,
|
||||
qualityVariant: item.preserveQualityVariant
|
||||
? qualityVariantStagingLabel(item.id)
|
||||
: '',
|
||||
);
|
||||
safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
||||
}
|
||||
String? finalSafFileName = safFileName;
|
||||
@@ -3625,6 +3637,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath != null) {
|
||||
final postProcessedPath = await _runPostProcessingHooks(
|
||||
filePath,
|
||||
trackToDownload,
|
||||
);
|
||||
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
|
||||
filePath = postProcessedPath;
|
||||
result['file_path'] = postProcessedPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath != null && item.preserveQualityVariant) {
|
||||
final variantOutcome = await _finalizeQualityVariantFilename(
|
||||
item: item,
|
||||
result: result,
|
||||
filePath: filePath,
|
||||
storageMode: effectiveSafMode ? 'saf' : 'app',
|
||||
downloadTreeUri: settings.downloadTreeUri,
|
||||
safRelativeDir: effectiveOutputDir,
|
||||
fileName: finalSafFileName ?? safFileName,
|
||||
);
|
||||
filePath = variantOutcome.filePath;
|
||||
if (variantOutcome.fileName != null) {
|
||||
finalSafFileName = variantOutcome.fileName;
|
||||
}
|
||||
if (variantOutcome.metadata != null) {
|
||||
probedFinalMetadata = variantOutcome.metadata;
|
||||
}
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.completed,
|
||||
@@ -3657,10 +3699,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (filePath != null) {
|
||||
await _runPostProcessingHooks(filePath, trackToDownload);
|
||||
}
|
||||
|
||||
// Album ReplayGain: update the accumulator path to the final file
|
||||
// location. For SAF downloads the metadata was embedded on a temp
|
||||
// copy, so the stored path still points there. Replace it with the
|
||||
|
||||
@@ -11,6 +11,18 @@ class _DecryptOutcome {
|
||||
const _DecryptOutcome(this.path, {this.newFileName, this.failStage});
|
||||
}
|
||||
|
||||
class _QualityVariantFileOutcome {
|
||||
final String filePath;
|
||||
final String? fileName;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const _QualityVariantFileOutcome({
|
||||
required this.filePath,
|
||||
this.fileName,
|
||||
this.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/// AC-4 repair only applies to MP4 containers; decrypt can also emit raw
|
||||
/// FLAC, which the native MP4 box parser would reject as corrupt.
|
||||
bool _isMp4Container(String path) {
|
||||
@@ -154,6 +166,33 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String uri, String fileName})?> _writeTempToSafUnique({
|
||||
required String treeUri,
|
||||
required String relativeDir,
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required String srcPath,
|
||||
String preservedSuffix = '',
|
||||
}) async {
|
||||
try {
|
||||
final result = await PlatformBridge.createUniqueSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
relativeDir: relativeDir,
|
||||
fileName: fileName,
|
||||
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 unique temp file to SAF: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeLrcToSaf({
|
||||
required String treeUri,
|
||||
required String relativeDir,
|
||||
@@ -210,6 +249,9 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
void Function(String path) addCleanup,
|
||||
)
|
||||
op,
|
||||
bool avoidOverwrite = false,
|
||||
String preservedSuffix = '',
|
||||
void Function(String fileName)? onPublishedFileName,
|
||||
}) async {
|
||||
final tempPath = await _copySafToTemp(uri);
|
||||
if (tempPath == null) return null;
|
||||
@@ -224,13 +266,32 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
final fileName = produced.$2;
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : '';
|
||||
final newUri = await _writeTempToSaf(
|
||||
treeUri: treeUri,
|
||||
relativeDir: relativeDir,
|
||||
fileName: fileName,
|
||||
mimeType: _mimeTypeForExt(ext),
|
||||
srcPath: outPath,
|
||||
);
|
||||
String? newUri;
|
||||
if (avoidOverwrite) {
|
||||
final published = await _writeTempToSafUnique(
|
||||
treeUri: treeUri,
|
||||
relativeDir: relativeDir,
|
||||
fileName: fileName,
|
||||
mimeType: _mimeTypeForExt(ext),
|
||||
srcPath: outPath,
|
||||
preservedSuffix: preservedSuffix,
|
||||
);
|
||||
newUri = published?.uri;
|
||||
if (published != null) {
|
||||
onPublishedFileName?.call(published.fileName);
|
||||
}
|
||||
} else {
|
||||
newUri = await _writeTempToSaf(
|
||||
treeUri: treeUri,
|
||||
relativeDir: relativeDir,
|
||||
fileName: fileName,
|
||||
mimeType: _mimeTypeForExt(ext),
|
||||
srcPath: outPath,
|
||||
);
|
||||
if (newUri != null) {
|
||||
onPublishedFileName?.call(fileName);
|
||||
}
|
||||
}
|
||||
if (newUri == null) return null;
|
||||
if (newUri != uri) {
|
||||
await _deleteSafFile(uri);
|
||||
@@ -252,6 +313,174 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<_QualityVariantFileOutcome> _finalizeQualityVariantFilename({
|
||||
required DownloadItem item,
|
||||
required Map<String, dynamic> result,
|
||||
required String filePath,
|
||||
required String storageMode,
|
||||
String? downloadTreeUri,
|
||||
String? safRelativeDir,
|
||||
String? fileName,
|
||||
}) async {
|
||||
if (!item.preserveQualityVariant || result['already_exists'] == true) {
|
||||
return _QualityVariantFileOutcome(filePath: filePath, fileName: fileName);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? metadata;
|
||||
try {
|
||||
metadata = await PlatformBridge.readFileMetadata(filePath);
|
||||
if (metadata['error'] != null) metadata = null;
|
||||
} catch (e) {
|
||||
_log.d('Quality variant metadata probe failed for $filePath: $e');
|
||||
}
|
||||
|
||||
final bitDepth = readPositiveInt(
|
||||
metadata?['bit_depth'] ?? result['actual_bit_depth'],
|
||||
);
|
||||
final sampleRate = readPositiveInt(
|
||||
metadata?['sample_rate'] ?? result['actual_sample_rate'],
|
||||
);
|
||||
final detectedFormat =
|
||||
normalizeAudioFormatValue(
|
||||
metadata?['audio_codec']?.toString() ??
|
||||
metadata?['codec']?.toString() ??
|
||||
metadata?['format']?.toString(),
|
||||
) ??
|
||||
normalizeAudioFormatValue(
|
||||
result['audio_codec']?.toString() ?? result['format']?.toString(),
|
||||
) ??
|
||||
normalizeAudioFormatValue(
|
||||
audioFormatForPath(filePath, fileName: fileName),
|
||||
);
|
||||
final bitrateKbps = readPositiveBitrateKbps(
|
||||
metadata?['bitrate'] ??
|
||||
metadata?['bit_rate'] ??
|
||||
result['bitrate'] ??
|
||||
result['actual_bitrate'],
|
||||
);
|
||||
final qualityLabel = buildQualityVariantFilenameLabel(
|
||||
detectedFormat: detectedFormat,
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
bitrateKbps: bitrateKbps,
|
||||
measuredQuality:
|
||||
result['_native_actual_quality']?.toString() ??
|
||||
result['quality']?.toString(),
|
||||
);
|
||||
if (qualityLabel == null) {
|
||||
_log.w(
|
||||
'Keeping collision-safe temporary quality label because the final '
|
||||
'audio specification could not be measured: $filePath',
|
||||
);
|
||||
return _QualityVariantFileOutcome(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
if (bitDepth != null) result['actual_bit_depth'] = bitDepth;
|
||||
if (sampleRate != null) result['actual_sample_rate'] = sampleRate;
|
||||
if (detectedFormat != null) result['audio_codec'] = detectedFormat;
|
||||
if (bitrateKbps != null && isLossyAudioFormat(detectedFormat)) {
|
||||
result['bitrate'] = bitrateKbps;
|
||||
}
|
||||
|
||||
final stagingLabel = qualityVariantStagingLabel(item.id);
|
||||
final localPathSegments = File(filePath).uri.pathSegments;
|
||||
final currentFileName = storageMode == 'saf' && isContentUri(filePath)
|
||||
? (fileName ?? result['file_name']?.toString() ?? '')
|
||||
: (localPathSegments.isEmpty ? '' : localPathSegments.last);
|
||||
final preferredFileName = applyQualityVariantFilenameLabel(
|
||||
fileName: currentFileName,
|
||||
stagingLabel: stagingLabel,
|
||||
qualityLabel: qualityLabel,
|
||||
);
|
||||
if (preferredFileName == currentFileName) {
|
||||
return _QualityVariantFileOutcome(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
if (storageMode == 'saf' && isContentUri(filePath)) {
|
||||
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
|
||||
return _QualityVariantFileOutcome(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
String? publishedFileName;
|
||||
final renamedUri = await _replaceSafFileVia(
|
||||
uri: filePath,
|
||||
treeUri: downloadTreeUri,
|
||||
relativeDir: safRelativeDir ?? '',
|
||||
avoidOverwrite: true,
|
||||
preservedSuffix: qualityLabel,
|
||||
onPublishedFileName: (name) => publishedFileName = name,
|
||||
op: (tempPath, addCleanup) async => (tempPath, preferredFileName),
|
||||
);
|
||||
if (renamedUri == null) {
|
||||
return _QualityVariantFileOutcome(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
final finalName = publishedFileName ?? preferredFileName;
|
||||
result['file_path'] = renamedUri;
|
||||
result['file_name'] = finalName;
|
||||
return _QualityVariantFileOutcome(
|
||||
filePath: renamedUri,
|
||||
fileName: finalName,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
final source = File(filePath);
|
||||
final parent = source.parent;
|
||||
var target = File(
|
||||
'${parent.path}${Platform.pathSeparator}$preferredFileName',
|
||||
);
|
||||
var counter = 2;
|
||||
while (await target.exists() && target.path != source.path) {
|
||||
final dotIndex = preferredFileName.lastIndexOf('.');
|
||||
final hasExtension = dotIndex > 0;
|
||||
final stem = hasExtension
|
||||
? preferredFileName.substring(0, dotIndex)
|
||||
: preferredFileName;
|
||||
final extension = hasExtension
|
||||
? preferredFileName.substring(dotIndex)
|
||||
: '';
|
||||
target = File(
|
||||
'${parent.path}${Platform.pathSeparator}$stem ($counter)$extension',
|
||||
);
|
||||
counter++;
|
||||
}
|
||||
try {
|
||||
final renamed = await source.rename(target.path);
|
||||
result['file_path'] = renamed.path;
|
||||
final renamedSegments = renamed.uri.pathSegments;
|
||||
result['file_name'] = renamedSegments.isEmpty
|
||||
? null
|
||||
: renamedSegments.last;
|
||||
return _QualityVariantFileOutcome(
|
||||
filePath: renamed.path,
|
||||
fileName: result['file_name'] as String?,
|
||||
metadata: metadata,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to apply measured quality filename: $e');
|
||||
return _QualityVariantFileOutcome(
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared decrypt finalize used by both the inline single-item pipeline
|
||||
/// and the native-worker pipeline. Divergences captured as parameters:
|
||||
/// [repairAc4] (inline repairs AC-4 containers using the still-encrypted
|
||||
|
||||
@@ -623,10 +623,19 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
_filenameMetadataForTrack(
|
||||
item.track,
|
||||
quality: quality,
|
||||
qualityVariant: item.preserveQualityVariant
|
||||
? qualityVariantStagingLabel(item.id)
|
||||
: '',
|
||||
playlistPosition: _validPlaylistPosition(item),
|
||||
),
|
||||
);
|
||||
safFileName = await _buildSafFileName(baseName, safOutputExt);
|
||||
safFileName = await _buildSafFileName(
|
||||
baseName,
|
||||
safOutputExt,
|
||||
qualityVariant: item.preserveQualityVariant
|
||||
? qualityVariantStagingLabel(item.id)
|
||||
: '',
|
||||
);
|
||||
}
|
||||
|
||||
var trackForPayload = item.track;
|
||||
@@ -921,14 +930,14 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
}
|
||||
|
||||
var actualQuality = context.quality;
|
||||
final actualBitDepth = result['actual_bit_depth'] as int?;
|
||||
final actualSampleRate = result['actual_sample_rate'] as int?;
|
||||
final actualFormat =
|
||||
var actualBitDepth = result['actual_bit_depth'] as int?;
|
||||
var actualSampleRate = result['actual_sample_rate'] as int?;
|
||||
var actualFormat =
|
||||
normalizeAudioFormatValue(
|
||||
result['audio_codec']?.toString() ?? result['format']?.toString(),
|
||||
) ??
|
||||
normalizeAudioFormatValue(audioFormatForPath(filePath));
|
||||
final actualBitrate = isLossyAudioFormat(actualFormat)
|
||||
var actualBitrate = isLossyAudioFormat(actualFormat)
|
||||
? readPositiveBitrateKbps(result['bitrate'] ?? result['actual_bitrate'])
|
||||
: null;
|
||||
final resolvedQuality = resolveDisplayQuality(
|
||||
@@ -994,6 +1003,53 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
}
|
||||
filePath = convertedContainerPath;
|
||||
|
||||
final postProcessedPath = await _runPostProcessingHooks(
|
||||
filePath,
|
||||
trackToDownload,
|
||||
);
|
||||
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
|
||||
filePath = postProcessedPath;
|
||||
}
|
||||
await _writeNativeWorkerReplayGain(
|
||||
context: context,
|
||||
settings: settings,
|
||||
track: trackToDownload,
|
||||
filePath: filePath,
|
||||
);
|
||||
|
||||
if (item.preserveQualityVariant) {
|
||||
final variantOutcome = await _finalizeQualityVariantFilename(
|
||||
item: item,
|
||||
result: result,
|
||||
filePath: filePath,
|
||||
storageMode: context.storageMode,
|
||||
downloadTreeUri: context.downloadTreeUri,
|
||||
safRelativeDir: context.safRelativeDir,
|
||||
fileName: result['file_name'] as String? ?? context.safFileName,
|
||||
);
|
||||
filePath = variantOutcome.filePath;
|
||||
actualBitDepth = readPositiveInt(result['actual_bit_depth']);
|
||||
actualSampleRate = readPositiveInt(result['actual_sample_rate']);
|
||||
actualFormat =
|
||||
normalizeAudioFormatValue(result['audio_codec']?.toString()) ??
|
||||
normalizeAudioFormatValue(audioFormatForPath(filePath));
|
||||
actualBitrate = isLossyAudioFormat(actualFormat)
|
||||
? readPositiveBitrateKbps(
|
||||
result['bitrate'] ?? result['actual_bitrate'],
|
||||
)
|
||||
: null;
|
||||
final finalQuality = resolveDisplayQuality(
|
||||
filePath: filePath,
|
||||
fileName: variantOutcome.fileName,
|
||||
detectedFormat: actualFormat,
|
||||
bitDepth: actualBitDepth,
|
||||
sampleRate: actualSampleRate,
|
||||
bitrateKbps: actualBitrate,
|
||||
storedQuality: actualQuality,
|
||||
);
|
||||
if (finalQuality != null) actualQuality = finalQuality;
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.completed,
|
||||
@@ -1024,19 +1080,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
onFetchError: (e) =>
|
||||
_log.w('Failed to fetch native-worker external LRC: $e'),
|
||||
);
|
||||
final postProcessedPath = await _runPostProcessingHooks(
|
||||
filePath,
|
||||
trackToDownload,
|
||||
);
|
||||
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
|
||||
filePath = postProcessedPath;
|
||||
}
|
||||
await _writeNativeWorkerReplayGain(
|
||||
context: context,
|
||||
settings: settings,
|
||||
track: trackToDownload,
|
||||
filePath: filePath,
|
||||
);
|
||||
_completedInSession++;
|
||||
|
||||
await _notificationService.showDownloadComplete(
|
||||
|
||||
@@ -207,10 +207,26 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
Future<String> _buildSafFileName(String baseName, String outputExt) async {
|
||||
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
|
||||
Future<String> _buildSafFileName(
|
||||
String baseName,
|
||||
String outputExt, {
|
||||
String qualityVariant = '',
|
||||
}) async {
|
||||
final extBytes = utf8.encode(outputExt).length;
|
||||
final maxBaseBytes = max(1, _maxSafFilenameUtf8Bytes - extBytes);
|
||||
if (qualityVariant.isNotEmpty && baseName.contains(qualityVariant)) {
|
||||
final rawPrefix = baseName
|
||||
.replaceAll(qualityVariant, '')
|
||||
.replaceFirst(RegExp(r'[\s_-]+$'), '');
|
||||
final sanitizedPrefix = await PlatformBridge.sanitizeFilename(rawPrefix);
|
||||
final suffix = ' - $qualityVariant';
|
||||
final prefixBytes = max(1, maxBaseBytes - utf8.encode(suffix).length);
|
||||
final truncatedPrefix = _trimSafeName(
|
||||
_truncateUtf8Bytes(sanitizedPrefix, prefixBytes),
|
||||
);
|
||||
return '$truncatedPrefix$suffix$outputExt';
|
||||
}
|
||||
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
|
||||
final truncated = _truncateUtf8Bytes(sanitized, maxBaseBytes);
|
||||
return '${_trimSafeName(truncated)}$outputExt';
|
||||
}
|
||||
@@ -541,9 +557,15 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
!_batchUniqueFilenameTokenPattern.hasMatch(effective)) {
|
||||
effective = '$effective - {track:02} - {title}';
|
||||
}
|
||||
if (item.preserveQualityVariant) {
|
||||
effective = effective.replaceAll(
|
||||
RegExp(r'\{quality\}', caseSensitive: false),
|
||||
'{quality_variant}',
|
||||
);
|
||||
}
|
||||
if (item.preserveQualityVariant &&
|
||||
!_qualityFilenameTokenPattern.hasMatch(effective)) {
|
||||
effective = '$effective - {quality}';
|
||||
effective = '$effective - {quality_variant}';
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
@@ -551,6 +573,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
Map<String, dynamic> _filenameMetadataForTrack(
|
||||
Track track, {
|
||||
required String quality,
|
||||
String qualityVariant = '',
|
||||
int playlistPosition = 0,
|
||||
}) {
|
||||
return {
|
||||
@@ -564,6 +587,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
|
||||
'playlist_position': playlistPosition,
|
||||
'playlistPosition': playlistPosition,
|
||||
'quality': quality,
|
||||
'quality_variant': qualityVariant,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class DownloadRequestPayload {
|
||||
final bool deferSafPublish;
|
||||
final bool requiresContainerConversion;
|
||||
final bool allowQualityVariant;
|
||||
final String qualityVariant;
|
||||
final String songLinkRegion;
|
||||
|
||||
const DownloadRequestPayload({
|
||||
@@ -100,6 +101,7 @@ class DownloadRequestPayload {
|
||||
this.deferSafPublish = false,
|
||||
this.requiresContainerConversion = false,
|
||||
this.allowQualityVariant = false,
|
||||
this.qualityVariant = '',
|
||||
this.songLinkRegion = 'US',
|
||||
});
|
||||
|
||||
@@ -153,6 +155,7 @@ class DownloadRequestPayload {
|
||||
'defer_saf_publish': deferSafPublish,
|
||||
'requires_container_conversion': requiresContainerConversion,
|
||||
'allow_quality_variant': allowQualityVariant,
|
||||
'quality_variant': qualityVariant,
|
||||
'songlink_region': songLinkRegion,
|
||||
};
|
||||
}
|
||||
@@ -210,6 +213,7 @@ class DownloadRequestPayload {
|
||||
deferSafPublish: deferSafPublish,
|
||||
requiresContainerConversion: requiresContainerConversion,
|
||||
allowQualityVariant: allowQualityVariant,
|
||||
qualityVariant: qualityVariant,
|
||||
songLinkRegion: songLinkRegion,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -642,6 +642,24 @@ class PlatformBridge {
|
||||
return result as String?;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> createUniqueSafFileFromPath({
|
||||
required String treeUri,
|
||||
required String relativeDir,
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
required String srcPath,
|
||||
String preservedSuffix = '',
|
||||
}) {
|
||||
return _invokeMap('safCreateUniqueFromPath', {
|
||||
'tree_uri': treeUri,
|
||||
'relative_dir': relativeDir,
|
||||
'file_name': fileName,
|
||||
'mime_type': mimeType,
|
||||
'src_path': srcPath,
|
||||
'preserved_suffix': preservedSuffix,
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> openContentUri(String uri, {String mimeType = ''}) async {
|
||||
await _channel.invokeMethod('openContentUri', {
|
||||
'uri': uri,
|
||||
|
||||
@@ -77,6 +77,87 @@ bool isLossyAudioFormat(String? value) {
|
||||
}.contains(normalizeAudioFormatValue(value));
|
||||
}
|
||||
|
||||
/// Returns a provider-independent quality label suitable for a filename.
|
||||
///
|
||||
/// Requested labels such as LOSSLESS and HI_RES are intentionally ignored:
|
||||
/// they describe provider intent, not the audio that was actually written.
|
||||
String? buildQualityVariantFilenameLabel({
|
||||
String? detectedFormat,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrateKbps,
|
||||
String? measuredQuality,
|
||||
}) {
|
||||
if (isLossyAudioFormat(detectedFormat)) {
|
||||
final effectiveBitrate =
|
||||
bitrateKbps ?? _bitrateFromQuality(measuredQuality);
|
||||
return effectiveBitrate != null && effectiveBitrate >= 16
|
||||
? '${effectiveBitrate}kbps'
|
||||
: null;
|
||||
}
|
||||
|
||||
final measured = _losslessSpecsFromQuality(measuredQuality);
|
||||
final effectiveBitDepth = bitDepth ?? measured?.$1;
|
||||
final effectiveSampleRate = sampleRate ?? measured?.$2;
|
||||
if (effectiveBitDepth == null ||
|
||||
effectiveBitDepth <= 0 ||
|
||||
effectiveSampleRate == null ||
|
||||
effectiveSampleRate <= 0) {
|
||||
return null;
|
||||
}
|
||||
return '${effectiveBitDepth}bit-${formatSampleRateKHz(effectiveSampleRate)}';
|
||||
}
|
||||
|
||||
int? _bitrateFromQuality(String? quality) {
|
||||
final match = RegExp(
|
||||
r'\b(\d+)\s*kbps\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(quality ?? '');
|
||||
final value = int.tryParse(match?.group(1) ?? '');
|
||||
return value != null && value >= 16 ? value : null;
|
||||
}
|
||||
|
||||
(int, int)? _losslessSpecsFromQuality(String? quality) {
|
||||
final match = RegExp(
|
||||
r'\b(\d+)\s*(?:-|\s)?bit\s*[/_-]\s*(\d+(?:\.\d+)?)\s*k?hz\b',
|
||||
caseSensitive: false,
|
||||
).firstMatch(quality ?? '');
|
||||
final bitDepth = int.tryParse(match?.group(1) ?? '');
|
||||
final rate = double.tryParse(match?.group(2) ?? '');
|
||||
if (bitDepth == null || bitDepth <= 0 || rate == null || rate <= 0) {
|
||||
return null;
|
||||
}
|
||||
final sampleRate = rate < 1000 ? (rate * 1000).round() : rate.round();
|
||||
return (bitDepth, sampleRate);
|
||||
}
|
||||
|
||||
String qualityVariantStagingLabel(String itemId) {
|
||||
var hash = 0x811c9dc5;
|
||||
for (final byte in itemId.codeUnits) {
|
||||
hash ^= byte;
|
||||
hash = (hash * 0x01000193) & 0xffffffff;
|
||||
}
|
||||
return 'qv_${hash.toRadixString(16).padLeft(8, '0')}';
|
||||
}
|
||||
|
||||
String applyQualityVariantFilenameLabel({
|
||||
required String fileName,
|
||||
required String stagingLabel,
|
||||
required String qualityLabel,
|
||||
}) {
|
||||
if (stagingLabel.isNotEmpty && fileName.contains(stagingLabel)) {
|
||||
return fileName.replaceAll(stagingLabel, qualityLabel);
|
||||
}
|
||||
if (fileName.contains(qualityLabel)) {
|
||||
return fileName;
|
||||
}
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final hasExtension = dotIndex > 0;
|
||||
final stem = hasExtension ? fileName.substring(0, dotIndex) : fileName;
|
||||
final extension = hasExtension ? fileName.substring(dotIndex) : '';
|
||||
return '$stem - $qualityLabel$extension';
|
||||
}
|
||||
|
||||
String lossyFormatForSetting(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (normalized.startsWith('opus')) return 'opus';
|
||||
|
||||
@@ -10,11 +10,72 @@ import 'package:spotiflac_android/services/app_remote_config_service.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
|
||||
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('quality variant filenames', () {
|
||||
test('uses measured lossless specifications instead of request labels', () {
|
||||
expect(
|
||||
buildQualityVariantFilenameLabel(
|
||||
detectedFormat: 'flac',
|
||||
bitDepth: 24,
|
||||
sampleRate: 96000,
|
||||
measuredQuality: 'LOSSLESS',
|
||||
),
|
||||
'24bit-96kHz',
|
||||
);
|
||||
expect(
|
||||
buildQualityVariantFilenameLabel(
|
||||
detectedFormat: 'flac',
|
||||
measuredQuality: 'LOSSLESS',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('uses measured bitrate for lossy output', () {
|
||||
expect(
|
||||
buildQualityVariantFilenameLabel(
|
||||
detectedFormat: 'mp3',
|
||||
bitrateKbps: 320,
|
||||
),
|
||||
'320kbps',
|
||||
);
|
||||
expect(
|
||||
buildQualityVariantFilenameLabel(
|
||||
detectedFormat: 'opus',
|
||||
measuredQuality: 'OPUS 256kbps',
|
||||
),
|
||||
'256kbps',
|
||||
);
|
||||
});
|
||||
|
||||
test('creates a stable temporary token and replaces only that token', () {
|
||||
final token = qualityVariantStagingLabel('queue-item-1');
|
||||
expect(token, matches(RegExp(r'^qv_[0-9a-f]{8}$')));
|
||||
expect(qualityVariantStagingLabel('queue-item-1'), token);
|
||||
expect(
|
||||
applyQualityVariantFilenameLabel(
|
||||
fileName: 'Artist - Song - $token.flac',
|
||||
stagingLabel: token,
|
||||
qualityLabel: '16bit-44.1kHz',
|
||||
),
|
||||
'Artist - Song - 16bit-44.1kHz.flac',
|
||||
);
|
||||
expect(
|
||||
applyQualityVariantFilenameLabel(
|
||||
fileName: 'Post Processed Song.flac',
|
||||
stagingLabel: token,
|
||||
qualityLabel: '16bit-44.1kHz',
|
||||
),
|
||||
'Post Processed Song - 16bit-44.1kHz.flac',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Library collections', () {
|
||||
test('keeps playlist membership without eagerly loading track JSON', () {
|
||||
final playlist = UserPlaylistCollection(
|
||||
@@ -427,6 +488,7 @@ void main() {
|
||||
safOutputExt: 'flac',
|
||||
outputExt: '.flac',
|
||||
allowQualityVariant: true,
|
||||
qualityVariant: 'qv_12345678',
|
||||
songLinkRegion: 'ID',
|
||||
);
|
||||
|
||||
@@ -479,6 +541,7 @@ void main() {
|
||||
'defer_saf_publish': false,
|
||||
'requires_container_conversion': false,
|
||||
'allow_quality_variant': true,
|
||||
'quality_variant': 'qv_12345678',
|
||||
'songlink_region': 'ID',
|
||||
});
|
||||
});
|
||||
@@ -501,6 +564,7 @@ void main() {
|
||||
expect(updated.trackName, payload.trackName);
|
||||
expect(updated.filenameFormat, payload.filenameFormat);
|
||||
expect(updated.allowQualityVariant, payload.allowQualityVariant);
|
||||
expect(updated.qualityVariant, payload.qualityVariant);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user