From b69f67a786813946d49cc6361edcf386beddb1f1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 16 Jul 2026 09:20:35 +0700 Subject: [PATCH] fix(download): name variants by measured quality --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 26 ++ .../zarz/spotiflac/NativeDownloadFinalizer.kt | 201 +++++++++++++-- .../com/zarz/spotiflac/SafDownloadHandler.kt | 102 ++++++++ go_backend/exports_download.go | 1 + go_backend/extension_fallback.go | 3 +- go_backend/filename.go | 30 ++- go_backend/filename_test.go | 33 +++ lib/providers/download_queue_provider.dart | 50 +++- .../download_queue_provider_finalization.dart | 243 +++++++++++++++++- ...download_queue_provider_native_worker.dart | 79 ++++-- .../download_queue_provider_paths.dart | 30 ++- lib/services/download_request_payload.dart | 4 + lib/services/platform_bridge.dart | 18 ++ lib/utils/audio_format_utils.dart | 81 ++++++ test/models_and_utils_test.dart | 64 +++++ 15 files changed, 904 insertions(+), 61 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 53cc7fa3..ec5b5d10 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2458,6 +2458,32 @@ class MainActivity: FlutterFragmentActivity() { } result.success(createdUri) } + "safCreateUniqueFromPath" -> { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val fileName = call.argument("file_name") ?: "" + val mimeType = call.argument("mime_type") ?: "application/octet-stream" + val srcPath = call.argument("src_path") ?: "" + val preservedSuffix = call.argument("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("uri") ?: "" val mimeType = call.argument("mime_type") ?: "" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index 761d9bfb..920eb2da 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt index 0c65c2d1..94d3258b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -26,6 +26,8 @@ object SafDownloadHandler { // the exists check and reports already_exists. private val safNameLocks = java.util.concurrent.ConcurrentHashMap() + data class UniqueWriteResult(val uri: String, val fileName: String) + private fun 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, diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go index 6b64d1df..cd4e6dbe 100644 --- a/go_backend/exports_download.go +++ b/go_backend/exports_download.go @@ -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"` } diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go index 16610b24..9bffd340 100644 --- a/go_backend/extension_fallback.go +++ b/go_backend/extension_fallback.go @@ -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 == "" { diff --git a/go_backend/filename.go b/go_backend/filename.go index 033c80df..5d152c7f 100644 --- a/go_backend/filename.go +++ b/go_backend/filename.go @@ -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 { diff --git a/go_backend/filename_test.go b/go_backend/filename_test.go index 78161293..d4013f83 100644 --- a/go_backend/filename_test.go +++ b/go_backend/filename_test.go @@ -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", diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 6c0ef56c..cd4642ae 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -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 { outputExt, ), allowQualityVariant: item.preserveQualityVariant, + qualityVariant: item.preserveQualityVariant + ? qualityVariantStagingLabel(item.id) + : '', songLinkRegion: settings.songLinkRegion, ); } @@ -2549,10 +2552,19 @@ class DownloadQueueNotifier extends Notifier { _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 { } } + 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 { ); } - 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 diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index db0a7d20..b7a1af5d 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -11,6 +11,18 @@ class _DecryptOutcome { const _DecryptOutcome(this.path, {this.newFileName, this.failStage}); } +class _QualityVariantFileOutcome { + final String filePath; + final String? fileName; + final Map? 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 _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 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? 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 diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 199e4ddd..9f52a433 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -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( diff --git a/lib/providers/download_queue_provider_paths.dart b/lib/providers/download_queue_provider_paths.dart index fa0f8fca..981ed193 100644 --- a/lib/providers/download_queue_provider_paths.dart +++ b/lib/providers/download_queue_provider_paths.dart @@ -207,10 +207,26 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { return parts.join('/'); } - Future _buildSafFileName(String baseName, String outputExt) async { - final sanitized = await PlatformBridge.sanitizeFilename(baseName); + Future _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 _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, }; } } diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index d17e88d6..104c33d1 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -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, ); } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index c512311c..3ecb986e 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -642,6 +642,24 @@ class PlatformBridge { return result as String?; } + static Future> 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 openContentUri(String uri, {String mimeType = ''}) async { await _channel.invokeMethod('openContentUri', { 'uri': uri, diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 9953722f..9902a499 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -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'; diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 729a6eb4..5dbb24f1 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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); }); });