diff --git a/README.md b/README.md
index af839c93..91bf53f9 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
-[](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
+[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
[](https://crowdin.com/project/spotiflac-mobile)
[](https://t.me/spotiflac)
@@ -141,6 +141,11 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
+> [!NOTE]
+> If SpotiFLAC is useful to you, consider supporting development:
+>
+> [](https://ko-fi.com/zarzet)
+
---
## Contributors
@@ -165,10 +170,5 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
-> [!NOTE]
-> If SpotiFLAC is useful to you, consider supporting development:
->
-> [](https://ko-fi.com/zarzet)
-
> [!TIP]
> **Star the repo** to get notified about all new releases directly from GitHub.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 0d290213..577a9667 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -9,6 +9,19 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
+analyzer:
+ exclude:
+ - build/**
+ - .dart_tool/**
+ - lib/**/*.g.dart
+ - lib/l10n/*.dart
+ language:
+ strict-casts: true
+ strict-inference: true
+ strict-raw-types: true
+ plugins:
+ - custom_lint
+
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
@@ -23,6 +36,13 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+ avoid_dynamic_calls: true
+ cancel_subscriptions: true
+ close_sinks: true
+
+custom_lint:
+ rules:
+ - avoid_public_notifier_properties
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt
index fe9b8e07..f05542bc 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt
@@ -137,14 +137,13 @@ class DownloadService : Service() {
private fun startForegroundService() {
isRunning = true
-
- // Acquire wake lock to prevent CPU sleep
+
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
WAKELOCK_TAG
).apply {
- acquire(60 * 60 * 1000L) // 1 hour max
+ acquire(60 * 60 * 1000L)
}
val notification = buildNotification(0, 0)
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 3fba0054..497da4cf 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
@@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
+import org.json.JSONTokener
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -129,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() {
)
companion object {
- // Minimum API level we consider "safe" for Impeller (Android 10+)
private const val SAFE_API_FOR_IMPELLER = 29
-
- // Known problematic GPU patterns (lowercase)
+
private val PROBLEMATIC_GPU_PATTERNS = listOf(
- "adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
- "adreno (tm) 4", // Adreno 400 series - some have issues
- "mali-4", // Mali-400 series - old ARM GPUs
- "mali-t6", // Mali-T600 series
- "mali-t7", // Mali-T700 series (some)
- "powervr sgx", // PowerVR SGX series - old Imagination GPUs
- "powervr ge8320", // PowerVR GE8320 - known issues
- "gc1000", // Vivante GC1000
- "gc2000", // Vivante GC2000
+ "adreno (tm) 3",
+ "adreno (tm) 4",
+ "mali-4",
+ "mali-t6",
+ "mali-t7",
+ "powervr sgx",
+ "powervr ge8320",
+ "gc1000",
+ "gc2000",
)
-
- // Known problematic chipsets/hardware (lowercase)
+
private val PROBLEMATIC_CHIPSETS = listOf(
- "mt6762", // MediaTek Helio P22 with PowerVR GE8320
- "mt6765", // MediaTek Helio P35 with PowerVR GE8320
- "mt8768", // MediaTek tablet chip
- "mp0873", // MediaTek variant
- "msm8974", // Snapdragon 800/801 with Adreno 330
- "msm8226", // Snapdragon 400 with Adreno 305
- "msm8926", // Snapdragon 400 with Adreno 305
- "apq8084", // Snapdragon 805 (some issues)
+ "mt6762",
+ "mt6765",
+ "mt8768",
+ "mp0873",
+ "msm8974",
+ "msm8226",
+ "msm8926",
+ "apq8084",
)
-
- // Known problematic device models (lowercase)
+
private val PROBLEMATIC_MODELS = listOf(
- "sm-t220", // Samsung Tab A7 Lite
- "sm-t225", // Samsung Tab A7 Lite LTE
- "hammerhead", // Nexus 5 (Adreno 330)
+ "sm-t220",
+ "sm-t225",
+ "hammerhead",
)
/**
* Check if device should use Skia instead of Impeller.
@@ -173,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() {
val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT)
- // 1. Check for explicitly problematic device models
for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
@@ -181,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // 2. Check for problematic chipsets
for (chipset in PROBLEMATIC_CHIPSETS) {
if (hardware.contains(chipset) || board.contains(chipset)) {
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
@@ -189,12 +184,9 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
- // For older Android, check GPU renderer if available
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
- // Check for known problematic GPUs
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
@@ -202,14 +194,12 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // For very old Android (< 8.0), always use Skia as Vulkan support is spotty
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
return true
}
}
- // 4. For Android 10+, still check for known problematic GPUs
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
@@ -227,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() {
*/
private fun getGpuRenderer(): String {
return try {
- // This might not work before GL context is created,
- // but worth trying for additional detection
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
} catch (e: Exception) {
""
@@ -316,6 +304,7 @@ class MainActivity: FlutterFragmentActivity() {
".mp3" -> "audio/mpeg"
".opus" -> "audio/ogg"
".flac" -> "audio/flac"
+ ".lrc" -> "application/octet-stream"
else -> "application/octet-stream"
}
}
@@ -413,6 +402,38 @@ class MainActivity: FlutterFragmentActivity() {
}
}
+ private fun parseJsonValue(value: Any?): Any? {
+ return when (value) {
+ null, JSONObject.NULL -> null
+ is JSONObject -> {
+ val map = LinkedHashMap
()
+ val keys = value.keys()
+ while (keys.hasNext()) {
+ val key = keys.next()
+ map[key] = parseJsonValue(value.opt(key))
+ }
+ map
+ }
+ is JSONArray -> {
+ val list = ArrayList()
+ for (i in 0 until value.length()) {
+ list.add(parseJsonValue(value.opt(i)))
+ }
+ list
+ }
+ is Number, is Boolean, is String -> value
+ else -> value.toString()
+ }
+ }
+
+ private fun parseJsonPayload(payload: String): Any {
+ return try {
+ parseJsonValue(JSONTokener(payload).nextValue()) ?: payload
+ } catch (_: Exception) {
+ payload
+ }
+ }
+
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
stopDownloadProgressStream()
downloadProgressEventSink = sink
@@ -425,7 +446,7 @@ class MainActivity: FlutterFragmentActivity() {
}
if (payload != lastDownloadProgressPayload) {
lastDownloadProgressPayload = payload
- sink.success(payload)
+ sink.success(parseJsonPayload(payload))
}
} catch (e: Exception) {
android.util.Log.w(
@@ -457,7 +478,7 @@ class MainActivity: FlutterFragmentActivity() {
}
if (payload != lastLibraryScanProgressPayload) {
lastLibraryScanProgressPayload = payload
- sink.success(payload)
+ sink.success(parseJsonPayload(payload))
}
} catch (e: Exception) {
android.util.Log.w(
@@ -599,7 +620,6 @@ class MainActivity: FlutterFragmentActivity() {
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
*/
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
- // Try DISPLAY_NAME first
try {
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
@@ -610,7 +630,6 @@ class MainActivity: FlutterFragmentActivity() {
}
} catch (_: Exception) {}
- // Try MIME_TYPE
try {
val mime = contentResolver.getType(uri)
val ext = extFromMimeType(mime)
@@ -836,8 +855,6 @@ class MainActivity: FlutterFragmentActivity() {
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
- // Check for existing file WITHOUT creating the directory first.
- // This prevents empty folders from being created for duplicate downloads.
val existingDir = findDocumentDir(treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
@@ -852,7 +869,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // Only create the directory now that we know we need to download
val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
@@ -875,7 +891,6 @@ class MainActivity: FlutterFragmentActivity() {
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
// Extension providers write to a local temp path instead of the SAF FD.
- // Copy the local file into the SAF document so it is not empty.
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
@@ -924,15 +939,10 @@ class MainActivity: FlutterFragmentActivity() {
try {
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
if (docId.isNullOrEmpty()) return null
-
- // Document IDs typically look like "primary:Music/Album/file.cue"
- // Parent would be "primary:Music/Album"
val lastSlash = docId.lastIndexOf('/')
if (lastSlash <= 0) return null
val parentDocId = docId.substring(0, lastSlash)
-
- // Build a tree document URI for the parent so it supports listing/findFile
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
if (treeDocId.isNullOrEmpty()) return null
@@ -957,21 +967,17 @@ class MainActivity: FlutterFragmentActivity() {
val lines = File(cueTempPath).readLines()
for (line in lines) {
val trimmed = line.trim().let { l ->
- // Strip BOM
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
}
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
val rest = trimmed.substring(5).trim()
- // Parse: "filename" TYPE or filename TYPE
val filename = if (rest.startsWith("\"")) {
val endQuote = rest.indexOf('"', 1)
if (endQuote > 0) rest.substring(1, endQuote) else rest
} else {
- // Last word is the type, everything else is the filename
val parts = rest.split("\\s+".toRegex())
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
}
- // Return just the filename (strip any path separators)
return filename.substringAfterLast("/").substringAfterLast("\\")
}
}
@@ -1056,7 +1062,6 @@ class MainActivity: FlutterFragmentActivity() {
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf>()
- // CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
val cueFiles = mutableListOf>()
val visitedDirUris = mutableSetOf()
val safChildLookupCache = mutableMapOf>()
@@ -1141,7 +1146,6 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
- // --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
val cueReferencedAudioUris = mutableSetOf()
for ((cueDoc, parentDir) in cueFiles) {
@@ -1180,10 +1184,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
- // Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
- // Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1197,7 +1199,6 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
- // Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1240,14 +1241,12 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
return "[]"
}
- // Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
@@ -1326,7 +1325,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
- // Parse existing files map: URI -> lastModified
val existingFiles = mutableMapOf()
try {
val obj = JSONObject(existingFilesJson)
@@ -1345,20 +1343,15 @@ class MainActivity: FlutterFragmentActivity() {
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
- val audioFiles = mutableListOf>() // doc, path, lastModified
- // CUE files to scan: (cueDoc, parentDir, lastModified)
+ val audioFiles = mutableListOf>()
val cueFilesToScan = mutableListOf>()
- // Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
val unchangedCueFiles = mutableListOf>()
val currentUris = mutableSetOf()
val visitedDirUris = mutableSetOf()
val safChildLookupCache = mutableMapOf>()
var traversalErrors = 0
- // Build a map of CUE base URIs -> existing virtual track URIs from the database.
- // Virtual paths look like "content://...album.cue#track01".
- // We need this to preserve virtual paths for unchanged CUE files.
- val existingCueVirtualPaths = mutableMapOf>() // cueUri -> [virtualPaths]
+ val existingCueVirtualPaths = mutableMapOf>()
for (key in existingFiles.keys) {
val hashIdx = key.indexOf("#track")
if (hashIdx > 0) {
@@ -1367,7 +1360,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // Collect all files with lastModified
val queue: ArrayDeque> = ArrayDeque()
queue.add(root to "")
@@ -1423,8 +1415,6 @@ class MainActivity: FlutterFragmentActivity() {
}
queue.add(child to childPath)
} else if (child.isFile) {
- // Mark file as present first so it cannot be mis-classified as removed
- // when provider-specific metadata calls (e.g., lastModified) fail.
val uriStr = child.uri.toString()
currentUris.add(uriStr)
@@ -1436,18 +1426,15 @@ class MainActivity: FlutterFragmentActivity() {
child.lastModified()
} catch (_: Exception) { 0L }
- // Check if any virtual track from this CUE exists with matching modTime
val virtualPaths = existingCueVirtualPaths[uriStr]
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
if (existingModified != null && existingModified == lastModified) {
- // CUE is unchanged — mark virtual paths as current so they aren't removed
unchangedCueFiles.add(child to dir)
for (vp in virtualPaths) {
currentUris.add(vp)
}
} else {
- // CUE is new or modified — needs scanning
cueFilesToScan.add(Triple(child, dir, lastModified))
}
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
@@ -1458,7 +1445,6 @@ class MainActivity: FlutterFragmentActivity() {
existingModified ?: 0L
}
- // Check if file is new or modified
if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified))
}
@@ -1475,7 +1461,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // Find removed files (in existing but not in current)
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
val totalFiles = currentUris.size
val filesToProcess = audioFiles.size + cueFilesToScan.size
@@ -1503,7 +1488,6 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
- // --- CUE first pass: parse new/modified CUE sheets ---
val cueReferencedAudioUris = mutableSetOf()
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
@@ -1524,7 +1508,6 @@ class MainActivity: FlutterFragmentActivity() {
var tempCuePath: String? = null
var tempAudioPath: String? = null
try {
- // Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) {
errors++
@@ -1533,10 +1516,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
- // Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath)
- // Find the referenced audio file as a sibling in the same SAF directory
val audioDoc = resolveCueAudioSibling(
parentDir = parentDir,
cueName = cueName,
@@ -1551,10 +1532,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
- // Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
- // Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1568,7 +1547,6 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
- // Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1576,7 +1554,6 @@ class MainActivity: FlutterFragmentActivity() {
tempAudioPath = renamedAudio.absolutePath
}
- // Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath,
tempDir,
@@ -1588,7 +1565,6 @@ class MainActivity: FlutterFragmentActivity() {
for (j in 0 until cueArray.length()) {
val trackObj = cueArray.getJSONObject(j)
results.put(trackObj)
- // Register each virtual path as current so deletion detection works
val virtualPath = trackObj.optString("filePath", "")
if (virtualPath.isNotBlank()) {
currentUris.add(virtualPath)
@@ -1621,9 +1597,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // Discover audio siblings for unchanged CUE files so we skip them
- // in the regular audio pass. Copy the .cue to temp (tiny file) to extract
- // the audio filename, then find the sibling by name.
for ((cueDoc, parentDir) in unchangedCueFiles) {
var tempCue: String? = null
try {
@@ -1648,7 +1621,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
@@ -1661,7 +1633,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
- // Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val processed = skippedCount + scanned
@@ -1715,7 +1686,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // Recalculate removedUris now that CUE virtual paths have been registered
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
updateSafScanProgress {
@@ -1893,7 +1863,6 @@ class MainActivity: FlutterFragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
- // Update the intent so receive_sharing_intent can access the new data
setIntent(intent)
}
@@ -2000,13 +1969,13 @@ class MainActivity: FlutterFragmentActivity() {
val response = withContext(Dispatchers.IO) {
Gobackend.getDownloadProgress()
}
- result.success(response)
+ result.success(parseJsonPayload(response))
}
"getAllDownloadProgress" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress()
}
- result.success(response)
+ result.success(parseJsonPayload(response))
}
"initItemProgress" -> {
val itemId = call.argument("item_id") ?: ""
@@ -2553,7 +2522,6 @@ class MainActivity: FlutterFragmentActivity() {
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
- // Replace file_path with temp path for Go
reqObj.put("file_path", tempPath)
val raw = Gobackend.reEnrichFile(reqObj.toString())
val obj = JSONObject(raw)
@@ -2631,7 +2599,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
- // Deezer API methods
"searchDeezerAll" -> {
val query = call.argument("query") ?: ""
val trackLimit = call.argument("track_limit") ?: 15
@@ -2642,7 +2609,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Tidal search API
"searchTidalAll" -> {
val query = call.argument("query") ?: ""
val trackLimit = call.argument("track_limit") ?: 15
@@ -2653,7 +2619,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Qobuz search API
"searchQobuzAll" -> {
val query = call.argument("query") ?: ""
val trackLimit = call.argument("track_limit") ?: 15
@@ -2783,7 +2748,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getLogs()
@@ -2816,7 +2780,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
- // Extension System methods
"initExtensionSystem" -> {
val extensionsDir = call.argument("extensions_dir") ?: ""
val dataDir = call.argument("data_dir") ?: ""
@@ -2961,7 +2924,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
- // Extension Auth API methods
"getExtensionPendingAuth" -> {
val extensionId = call.argument("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3011,7 +2973,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Extension FFmpeg API
"getPendingFFmpegCommand" -> {
val commandId = call.argument("command_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3039,7 +3000,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Extension Custom Search API
"customSearchWithExtension" -> {
val extensionId = call.argument("extension_id") ?: ""
val query = call.argument("query") ?: ""
@@ -3055,7 +3015,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Extension URL Handler API
"handleURLWithExtension" -> {
val url = call.argument("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3100,7 +3059,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Extension Post-Processing API
"runPostProcessing" -> {
val filePath = call.argument("file_path") ?: ""
val metadataJson = call.argument("metadata") ?: ""
@@ -3144,7 +3102,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Extension Store
"initExtensionStore" -> {
val cacheDir = call.argument("cache_dir") ?: ""
withContext(Dispatchers.IO) {
@@ -3206,7 +3163,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
- // Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3221,7 +3177,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // Local Library Scanning
"setLibraryCoverCacheDir" -> {
val cacheDir = call.argument("cache_dir") ?: ""
withContext(Dispatchers.IO) {
@@ -3298,7 +3253,7 @@ class MainActivity: FlutterFragmentActivity() {
Gobackend.getLibraryScanProgressJSON()
}
}
- result.success(response)
+ result.success(parseJsonPayload(response))
}
"cancelLibraryScan" -> {
withContext(Dispatchers.IO) {
@@ -3326,7 +3281,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- // CUE Sheet Parsing
"parseCueSheet" -> {
val cuePath = call.argument("cue_path") ?: ""
val audioDir = call.argument("audio_dir") ?: ""
@@ -3338,17 +3292,14 @@ class MainActivity: FlutterFragmentActivity() {
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
var tempAudioPath: String? = null
try {
- // Extract audio filename from CUE text
val audioFileName = extractCueAudioFileName(tempCuePath)
- // Try to find the audio sibling in SAF
var audioDoc: DocumentFile? = null
val parentDir = safParentDir(uri)
if (parentDir != null && !audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
- // Fallback: try common extensions with the CUE base name
if (audioDoc == null && parentDir != null) {
val cueName = try {
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
@@ -3367,7 +3318,6 @@ class MainActivity: FlutterFragmentActivity() {
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
if (audioDoc != null) {
- // Copy audio to same temp dir with original name
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
@@ -3382,15 +3332,11 @@ class MainActivity: FlutterFragmentActivity() {
}
}
- // Parse with audio in temp dir; Go will resolve there
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
- // Replace the temp audio_path with the SAF content:// URI
- // so Dart knows it's a SAF file and handles it accordingly
if (audioDoc != null) {
val resultObj = JSONObject(resultJson)
resultObj.put("audio_path", audioDoc.uri.toString())
- // Also pass the original CUE URI for reference
resultObj.put("cue_path", cuePath)
resultObj.toString()
} else {
diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go
index 7a906f8e..8f1b2921 100644
--- a/go_backend/audio_metadata.go
+++ b/go_backend/audio_metadata.go
@@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) {
- case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
+ case
+ "lyrics",
+ "lyric",
+ "unsyncedlyrics",
+ "unsynced lyrics",
+ "uslt",
+ "lrc":
return true
default:
return false
diff --git a/go_backend/audio_metadata_mp3_test.go b/go_backend/audio_metadata_mp3_test.go
new file mode 100644
index 00000000..b63f7b28
--- /dev/null
+++ b/go_backend/audio_metadata_mp3_test.go
@@ -0,0 +1,133 @@
+package gobackend
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func ffmpegCommand(args ...string) *exec.Cmd {
+ if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
+ return exec.Command(ffmpegPath, args...)
+ }
+ return exec.Command("ffmpeg", args...)
+}
+
+func runFFmpegTestCommand(t *testing.T, args ...string) {
+ t.Helper()
+ cmd := ffmpegCommand(args...)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
+ }
+}
+
+func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
+ if _, err := exec.LookPath("ffmpeg"); err != nil {
+ t.Skip("ffmpeg not available")
+ }
+
+ tempDir := t.TempDir()
+ sourceFlac := filepath.Join(tempDir, "source.flac")
+ baseMp3 := filepath.Join(tempDir, "base.mp3")
+ finalMp3 := filepath.Join(tempDir, "final.mp3")
+ coverPath := filepath.Join(tempDir, "cover.jpg")
+ lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
+
+ runFFmpegTestCommand(
+ t,
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=1",
+ "-c:a",
+ "flac",
+ sourceFlac,
+ )
+
+ runFFmpegTestCommand(
+ t,
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=red:s=32x32:d=1",
+ "-frames:v",
+ "1",
+ coverPath,
+ )
+
+ runFFmpegTestCommand(
+ t,
+ "-y",
+ "-i",
+ sourceFlac,
+ "-b:a",
+ "320k",
+ "-metadata",
+ "title=Test Song",
+ "-metadata",
+ "artist=Test Artist",
+ "-metadata",
+ "lyrics="+lyrics,
+ baseMp3,
+ )
+
+ runFFmpegTestCommand(
+ t,
+ "-y",
+ "-i",
+ baseMp3,
+ "-i",
+ coverPath,
+ "-map",
+ "0:a",
+ "-map_metadata",
+ "-1",
+ "-map",
+ "1:0",
+ "-c:v:0",
+ "copy",
+ "-id3v2_version",
+ "3",
+ "-metadata",
+ "title=Test Song",
+ "-metadata",
+ "artist=Test Artist",
+ "-metadata",
+ "lyrics="+lyrics,
+ "-metadata:s:v",
+ "title=Album cover",
+ "-metadata:s:v",
+ "comment=Cover (front)",
+ "-c:a",
+ "copy",
+ finalMp3,
+ )
+
+ meta, err := ReadID3Tags(finalMp3)
+ if err != nil {
+ t.Fatalf("ReadID3Tags failed: %v", err)
+ }
+ if meta == nil {
+ t.Fatalf("ReadID3Tags returned nil metadata")
+ }
+
+ embeddedLyrics, err := ExtractLyrics(finalMp3)
+ if err != nil {
+ t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
+ }
+ if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
+ t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
+ }
+ if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
+ t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
+ }
+
+ if _, err := os.Stat(finalMp3); err != nil {
+ t.Fatalf("expected final mp3 to exist: %v", err)
+ }
+}
diff --git a/go_backend/cover.go b/go_backend/cover.go
index 10c89963..a368fb8f 100644
--- a/go_backend/cover.go
+++ b/go_backend/cover.go
@@ -17,6 +17,8 @@ const (
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
+var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
+
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
- // Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
}
func upgradeToMaxQuality(coverURL string) string {
- // Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
- // Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
+ if strings.Contains(coverURL, "resources.tidal.com") {
+ return upgradeTidalCover(coverURL)
+ }
+
+ if strings.Contains(coverURL, "static.qobuz.com") {
+ return upgradeQobuzCover(coverURL)
+ }
+
return coverURL
}
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
return upgraded
}
+func upgradeTidalCover(coverURL string) string {
+ if !strings.Contains(coverURL, "resources.tidal.com") {
+ return coverURL
+ }
+
+ upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
+ if upgraded != coverURL {
+ GoLog("[Cover] Tidal: upgraded to origin resolution")
+ }
+ return upgraded
+}
+
+func upgradeQobuzCover(coverURL string) string {
+ if !strings.Contains(coverURL, "static.qobuz.com") {
+ return coverURL
+ }
+
+ upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
+ if upgraded != coverURL {
+ GoLog("[Cover] Qobuz: upgraded to max resolution")
+ }
+ return upgraded
+}
+
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" {
return ""
}
- // Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go
index 8cbf1f61..1a0dfa06 100644
--- a/go_backend/cue_parser.go
+++ b/go_backend/cue_parser.go
@@ -13,7 +13,6 @@ import (
// CueSheet represents a parsed .cue file
type CueSheet struct {
- // Album-level metadata
Performer string `json:"performer"`
Title string `json:"title"`
FileName string `json:"file_name"`
@@ -32,7 +31,6 @@ type CueTrack struct {
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
- // Index positions in seconds (fractional)
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
@@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
- // Handle BOM at start of file
if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line)
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
upper := strings.ToUpper(line)
- // REM commands (album-level metadata)
if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 {
@@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
- // Extract filename and type
- // Format: FILE "filename.flac" WAVE
- // or: FILE filename.flac WAVE
fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname
sheet.FileType = ftype
@@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
}
if strings.HasPrefix(upper, "TRACK ") {
- // Save previous track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
@@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
- // SONGWRITER (used as composer sometimes)
if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil {
@@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
}
}
- // Don't forget the last track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
diff --git a/go_backend/deezer.go b/go_backend/deezer.go
index a389f3c8..3d76bcbf 100644
--- a/go_backend/deezer.go
+++ b/go_backend/deezer.go
@@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
- delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
+ delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
@@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
lastErr = err
errStr := err.Error()
- // Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go
index 974ffcf6..fdd0850d 100644
--- a/go_backend/deezer_download.go
+++ b/go_backend/deezer_download.go
@@ -319,7 +319,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
return "", fmt.Errorf("MusicDL error: %s", errMsg)
}
- // Try various response fields for download URL
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
diff --git a/go_backend/exports.go b/go_backend/exports.go
index 39184350..a5d17122 100644
--- a/go_backend/exports.go
+++ b/go_backend/exports.go
@@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
}
// SetSongLinkNetworkOptions is kept for backward compatibility.
-// It now applies global network compatibility options for all backend API requests.
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
}
@@ -407,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) {
}
}
err = deezerErr
- case "youtube":
- youtubeResult, youtubeErr := downloadFromYouTube(req)
- if youtubeErr == nil {
- result = DownloadResult{
- FilePath: youtubeResult.FilePath,
- BitDepth: 0,
- SampleRate: 0,
- Title: youtubeResult.Title,
- Artist: youtubeResult.Artist,
- Album: youtubeResult.Album,
- ReleaseDate: youtubeResult.ReleaseDate,
- TrackNumber: youtubeResult.TrackNumber,
- DiscNumber: youtubeResult.DiscNumber,
- ISRC: youtubeResult.ISRC,
- LyricsLRC: youtubeResult.LyricsLRC,
- }
- }
- err = youtubeErr
default:
return errorResponse("Unknown service: " + req.Service)
}
@@ -476,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req
- if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
+ if isBuiltInProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized
}
@@ -486,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
}
normalizedJSON := string(normalizedBytes)
- if serviceNormalized == "youtube" {
- return DownloadFromYouTube(normalizedJSON)
- }
-
if req.UseExtensions {
// Respect strict mode when auto fallback is disabled:
// for built-in providers, route directly to selected service only.
@@ -721,29 +698,57 @@ func ReadFileMetadata(filePath string) (string, error) {
if isFlac {
metadata, err := ReadMetadata(filePath)
if err != nil {
- return "", fmt.Errorf("failed to read metadata: %w", err)
- }
- result["title"] = metadata.Title
- result["artist"] = metadata.Artist
- result["album"] = metadata.Album
- result["album_artist"] = metadata.AlbumArtist
- result["date"] = metadata.Date
- result["track_number"] = metadata.TrackNumber
- result["disc_number"] = metadata.DiscNumber
- result["isrc"] = metadata.ISRC
- result["lyrics"] = metadata.Lyrics
- result["genre"] = metadata.Genre
- result["label"] = metadata.Label
- result["copyright"] = metadata.Copyright
- result["composer"] = metadata.Composer
- result["comment"] = metadata.Comment
+ // File may have wrong extension (e.g. opus saved as .flac).
+ // Try Ogg/Opus parser as fallback before giving up.
+ GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
+ oggMeta, oggErr := ReadOggVorbisComments(filePath)
+ if oggErr == nil && oggMeta != nil {
+ result["title"] = oggMeta.Title
+ result["artist"] = oggMeta.Artist
+ result["album"] = oggMeta.Album
+ result["album_artist"] = oggMeta.AlbumArtist
+ result["date"] = oggMeta.Date
+ if oggMeta.Date == "" {
+ result["date"] = oggMeta.Year
+ }
+ result["track_number"] = oggMeta.TrackNumber
+ result["disc_number"] = oggMeta.DiscNumber
+ result["isrc"] = oggMeta.ISRC
+ result["lyrics"] = oggMeta.Lyrics
+ result["genre"] = oggMeta.Genre
+ result["composer"] = oggMeta.Composer
+ result["comment"] = oggMeta.Comment
+ quality, qualityErr := GetOggQuality(filePath)
+ if qualityErr == nil {
+ result["sample_rate"] = quality.SampleRate
+ result["duration"] = quality.Duration
+ }
+ } else {
+ return "", fmt.Errorf("failed to read metadata: %w", err)
+ }
+ } else {
+ result["title"] = metadata.Title
+ result["artist"] = metadata.Artist
+ result["album"] = metadata.Album
+ result["album_artist"] = metadata.AlbumArtist
+ result["date"] = metadata.Date
+ result["track_number"] = metadata.TrackNumber
+ result["disc_number"] = metadata.DiscNumber
+ result["isrc"] = metadata.ISRC
+ result["lyrics"] = metadata.Lyrics
+ result["genre"] = metadata.Genre
+ result["label"] = metadata.Label
+ result["copyright"] = metadata.Copyright
+ result["composer"] = metadata.Composer
+ result["comment"] = metadata.Comment
- quality, qualityErr := GetAudioQuality(filePath)
- if qualityErr == nil {
- result["bit_depth"] = quality.BitDepth
- result["sample_rate"] = quality.SampleRate
- if quality.SampleRate > 0 && quality.TotalSamples > 0 {
- result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
+ quality, qualityErr := GetAudioQuality(filePath)
+ if qualityErr == nil {
+ result["bit_depth"] = quality.BitDepth
+ result["sample_rate"] = quality.SampleRate
+ if quality.SampleRate > 0 && quality.TotalSamples > 0 {
+ result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
+ }
}
}
} else if isM4A {
@@ -910,7 +915,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
- // MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{
"success": true,
"method": "ffmpeg",
@@ -1670,62 +1674,6 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil
}
-func DownloadFromYouTube(requestJSON string) (string, error) {
- var req DownloadRequest
- if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
- return errorResponse("Invalid request: " + err.Error())
- }
- applySongLinkRegionFromRequest(&req)
- defer closeOwnedOutputFD(req.OutputFD)
-
- req.TrackName = strings.TrimSpace(req.TrackName)
- req.ArtistName = strings.TrimSpace(req.ArtistName)
- req.AlbumName = strings.TrimSpace(req.AlbumName)
- req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
- req.OutputDir = strings.TrimSpace(req.OutputDir)
- req.OutputPath = strings.TrimSpace(req.OutputPath)
- req.OutputExt = strings.TrimSpace(req.OutputExt)
-
- if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
- AddAllowedDownloadDir(req.OutputDir)
- }
-
- youtubeResult, err := downloadFromYouTube(req)
- if err != nil {
- return errorResponse(err.Error())
- }
-
- resp := DownloadResponse{
- Success: true,
- Message: "Downloaded from YouTube",
- FilePath: youtubeResult.FilePath,
- Service: "youtube",
- Title: youtubeResult.Title,
- Artist: youtubeResult.Artist,
- Album: youtubeResult.Album,
- ReleaseDate: youtubeResult.ReleaseDate,
- TrackNumber: youtubeResult.TrackNumber,
- DiscNumber: youtubeResult.DiscNumber,
- ISRC: youtubeResult.ISRC,
- LyricsLRC: youtubeResult.LyricsLRC,
- CoverURL: req.CoverURL,
- Genre: req.Genre,
- Label: req.Label,
- Copyright: req.Copyright,
- }
-
- jsonBytes, _ := json.Marshal(resp)
- return string(jsonBytes), nil
-}
-
-func IsYouTubeURLExport(urlStr string) bool {
- return IsYouTubeURL(urlStr)
-}
-
-func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
- return ExtractYouTubeVideoID(urlStr)
-}
-
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
@@ -1958,7 +1906,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
- // Log metadata summary before embedding
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
@@ -2041,7 +1988,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
- // Build enriched metadata response for Dart (includes online search results)
enrichedMeta := map[string]interface{}{
"track_name": req.TrackName,
"artist_name": req.ArtistName,
@@ -2187,12 +2133,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err
}
- settingsStore := GetExtensionSettingsStore()
- settings := settingsStore.GetAll(ext.ID)
- if len(settings) > 0 {
- manager.InitializeExtension(ext.ID, settings)
- }
-
result := map[string]interface{}{
"id": ext.ID,
"name": ext.Manifest.Name,
@@ -2226,12 +2166,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err
}
- settingsStore := GetExtensionSettingsStore()
- settings := settingsStore.GetAll(ext.ID)
- if len(settings) > 0 {
- manager.InitializeExtension(ext.ID, settings)
- }
-
result := map[string]interface{}{
"id": ext.ID,
"display_name": ext.Manifest.DisplayName,
@@ -3177,17 +3111,17 @@ func GetPostProcessingProvidersJSON() (string, error) {
}
func InitExtensionStoreJSON(cacheDir string) error {
- InitExtensionStore(cacheDir)
+ initExtensionStore(cacheDir)
return nil
}
func SetStoreRegistryURLJSON(registryURL string) error {
- store := GetExtensionStore()
+ store := getExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
- resolved, err := ResolveRegistryURL(registryURL)
+ resolved, err := resolveRegistryURL(registryURL)
if err != nil {
return err
}
@@ -3196,41 +3130,37 @@ func SetStoreRegistryURLJSON(registryURL string) error {
return err
}
- store.SetRegistryURL(resolved)
+ store.setRegistryURL(resolved)
return nil
}
func ClearStoreRegistryURLJSON() error {
- store := GetExtensionStore()
+ store := getExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
- store.SetRegistryURL("")
- store.ClearCache()
+ store.setRegistryURL("")
+ store.clearCache()
return nil
}
func GetStoreRegistryURLJSON() (string, error) {
- store := GetExtensionStore()
+ store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
- return store.GetRegistryURL(), nil
+ return store.getRegistryURL(), nil
}
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
- store := GetExtensionStore()
+ store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
- if forceRefresh {
- store.FetchRegistry(true)
- }
-
- extensions, err := store.GetExtensionsWithStatus()
+ extensions, err := store.getExtensionsWithStatus(forceRefresh)
if err != nil {
return "", err
}
@@ -3244,12 +3174,12 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
}
func SearchStoreExtensionsJSON(query, category string) (string, error) {
- store := GetExtensionStore()
+ store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
- extensions, err := store.SearchExtensions(query, category)
+ extensions, err := store.searchExtensions(query, category)
if err != nil {
return "", err
}
@@ -3263,12 +3193,12 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) {
}
func GetStoreCategoriesJSON() (string, error) {
- store := GetExtensionStore()
+ store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
- categories := store.GetCategories()
+ categories := store.getCategories()
jsonBytes, err := json.Marshal(categories)
if err != nil {
return "", err
@@ -3287,7 +3217,7 @@ func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
}
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
- store := GetExtensionStore()
+ store := getExtensionStore()
if store == nil {
return "", fmt.Errorf("extension store not initialized")
}
@@ -3296,7 +3226,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
if err != nil {
return "", err
}
- err = store.DownloadExtension(extensionID, destPath)
+ err = store.downloadExtension(extensionID, destPath)
if err != nil {
return "", err
}
@@ -3305,12 +3235,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
}
func ClearStoreCacheJSON() error {
- store := GetExtensionStore()
+ store := getExtensionStore()
if store == nil {
return fmt.Errorf("extension store not initialized")
}
- store.ClearCache()
+ store.clearCache()
return nil
}
@@ -3324,12 +3254,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
+ vm, err := ext.lockReadyVM()
+ if err != nil {
+ return "", err
+ }
+ defer ext.VMMu.Unlock()
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
- ext.VMMu.Lock()
- defer ext.VMMu.Unlock()
-
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
@@ -3339,7 +3271,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
})()
`, functionName, functionName)
- result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
+ result, err := RunWithTimeoutAndRecover(vm, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go
index d2a1f8d3..8f9e5dfd 100644
--- a/go_backend/extension_manager.go
+++ b/go_backend/extension_manager.go
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
}
type LoadedExtension struct {
- ID string `json:"id"`
- Manifest *ExtensionManifest `json:"manifest"`
- VM *goja.Runtime `json:"-"`
- VMMu sync.Mutex `json:"-"`
- runtime *ExtensionRuntime
- Enabled bool `json:"enabled"`
- Error string `json:"error,omitempty"`
- DataDir string `json:"data_dir"`
- SourceDir string `json:"source_dir"`
- IconPath string `json:"icon_path"`
+ ID string `json:"id"`
+ Manifest *ExtensionManifest `json:"manifest"`
+ VM *goja.Runtime `json:"-"`
+ VMMu sync.Mutex `json:"-"`
+ runtime *ExtensionRuntime
+ initialized bool
+ Enabled bool `json:"enabled"`
+ Error string `json:"error,omitempty"`
+ DataDir string `json:"data_dir"`
+ SourceDir string `json:"source_dir"`
+ IconPath string `json:"icon_path"`
+}
+
+func getExtensionInitSettings(extensionID string) map[string]interface{} {
+ settings := GetExtensionSettingsStore().GetAll(extensionID)
+ if len(settings) == 0 {
+ return settings
+ }
+
+ filtered := make(map[string]interface{}, len(settings))
+ for key, value := range settings {
+ if strings.HasPrefix(key, "_") {
+ continue
+ }
+ filtered[key] = value
+ }
+ return filtered
+}
+
+func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
+ if ext.VM == nil || ext.runtime == nil {
+ if err := initializeVMLocked(ext); err != nil {
+ ext.Error = err.Error()
+ ext.Enabled = false
+ return err
+ }
+ }
+
+ if applyStoredSettings && !ext.initialized {
+ settings := getExtensionInitSettings(ext.ID)
+ if len(settings) > 0 {
+ if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
+ teardownVMLocked(ext)
+ ext.Error = err.Error()
+ ext.Enabled = false
+ return err
+ }
+ } else {
+ ext.initialized = true
+ }
+ }
+
+ ext.Error = ""
+ return nil
+}
+
+func (ext *LoadedExtension) ensureRuntimeReady() error {
+ ext.VMMu.Lock()
+ defer ext.VMMu.Unlock()
+
+ return ensureRuntimeReadyLocked(ext, true)
+}
+
+func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
+ ext.VMMu.Lock()
+ if err := ensureRuntimeReadyLocked(ext, true); err != nil {
+ ext.VMMu.Unlock()
+ return nil, err
+ }
+ return ext.VM, nil
}
type ExtensionManager struct {
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
SourceDir: extDir,
}
- if err := m.initializeVM(ext); err != nil {
+ if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
- GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
+ GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
}
m.extensions[manifest.Name] = ext
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil
}
-func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
+func initializeVMLocked(ext *LoadedExtension) error {
+ ext.VM = nil
+ ext.runtime = nil
+ ext.initialized = false
vm := goja.New()
ext.VM = vm
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return nil
}
+func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
+ ext.VMMu.Lock()
+ defer ext.VMMu.Unlock()
+ return initializeVMLocked(ext)
+}
+
+func initializeExtensionWithSettingsLocked(
+ ext *LoadedExtension,
+ settings map[string]interface{},
+) error {
+ if ext.VM == nil {
+ return fmt.Errorf("Extension failed to load. Please reinstall the extension")
+ }
+
+ settingsJSON, err := json.Marshal(settings)
+ if err != nil {
+ return fmt.Errorf("Failed to save settings")
+ }
+
+ script := fmt.Sprintf(`
+ (function() {
+ var settings = %s;
+ if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
+ try {
+ extension.initialize(settings);
+ return { success: true };
+ } catch (e) {
+ return { success: false, error: e.toString() };
+ }
+ }
+ return { success: true, message: 'no initialize function' };
+ })()
+ `, string(settingsJSON))
+
+ result, err := ext.VM.RunString(script)
+ if err != nil {
+ ext.Error = fmt.Sprintf("initialize failed: %v", err)
+ ext.Enabled = false
+ GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
+ return err
+ }
+
+ if result != nil && !goja.IsUndefined(result) {
+ exported := result.Export()
+ if resultMap, ok := exported.(map[string]interface{}); ok {
+ if success, ok := resultMap["success"].(bool); ok && !success {
+ errMsg := "unknown error"
+ if e, ok := resultMap["error"].(string); ok {
+ errMsg = e
+ }
+ ext.Error = errMsg
+ ext.Enabled = false
+ GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
+ return fmt.Errorf("initialize failed: %s", errMsg)
+ }
+ }
+ }
+
+ ext.initialized = true
+ GoLog("[Extension] Initialized %s\n", ext.ID)
+ return nil
+}
+
+func runCleanupLocked(ext *LoadedExtension) error {
+ if ext.VM != nil {
+ script := `
+ (function() {
+ if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
+ try {
+ extension.cleanup();
+ return { success: true };
+ } catch (e) {
+ return { success: false, error: e.toString() };
+ }
+ }
+ return { success: true, message: 'no cleanup function' };
+ })()
+ `
+
+ result, err := ext.VM.RunString(script)
+ if err != nil {
+ return err
+ }
+
+ if result != nil && !goja.IsUndefined(result) {
+ exported := result.Export()
+ if resultMap, ok := exported.(map[string]interface{}); ok {
+ if success, ok := resultMap["success"].(bool); ok && !success {
+ errMsg := "unknown error"
+ if e, ok := resultMap["error"].(string); ok {
+ errMsg = e
+ }
+ return fmt.Errorf("cleanup failed: %s", errMsg)
+ }
+ }
+ }
+
+ if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
+ GoLog("[Extension] Cleanup called for %s\n", ext.ID)
+ }
+ }
+ return nil
+}
+
+func teardownVMLocked(ext *LoadedExtension) {
+ if err := runCleanupLocked(ext); err != nil {
+ GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
+ }
+ if ext.runtime != nil {
+ if err := ext.runtime.flushStorageNow(); err != nil {
+ GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
+ }
+ ext.runtime.closeStorageFlusher()
+ }
+ ext.runtime = nil
+ ext.VM = nil
+ ext.initialized = false
+}
+
+func validateExtensionLoad(ext *LoadedExtension) error {
+ ext.VMMu.Lock()
+ defer ext.VMMu.Unlock()
+
+ if err := initializeVMLocked(ext); err != nil {
+ return err
+ }
+ teardownVMLocked(ext)
+ return nil
+}
+
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return fmt.Errorf("Extension not found")
}
- if ext.VM != nil {
- cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
- if err != nil {
- GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
- } else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
- GoLog("[Extension] Cleanup called for %s\n", extensionID)
- }
- }
- if ext.runtime != nil {
- if err := ext.runtime.flushStorageNow(); err != nil {
- GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
- }
- ext.runtime.closeStorageFlusher()
- ext.runtime = nil
- }
+ ext.VMMu.Lock()
+ teardownVMLocked(ext)
+ ext.VMMu.Unlock()
delete(m.extensions, extensionID)
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
@@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return fmt.Errorf("Extension not found")
}
- ext.Enabled = enabled
+ if enabled {
+ ext.Enabled = true
+ if err := ext.ensureRuntimeReady(); err != nil {
+ store := GetExtensionSettingsStore()
+ ext.Enabled = false
+ _ = store.Set(extensionID, "_enabled", false)
+ return err
+ }
+ } else {
+ ext.Enabled = false
+ ext.Error = ""
+ ext.VMMu.Lock()
+ teardownVMLocked(ext)
+ ext.VMMu.Unlock()
+ }
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
store := GetExtensionSettingsStore()
@@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
}
}
- if err := m.initializeVM(ext); err != nil {
+ if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
- GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
+ GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
}
m.extensions[manifest.Name] = ext
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
SourceDir: extDir,
}
- if err := m.initializeVM(ext); err != nil {
+ if wasEnabled {
+ if err := ext.ensureRuntimeReady(); err != nil {
+ GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
+ }
+ } else if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
- GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
+ GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
}
m.mu.Lock()
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension not found")
}
- if ext.VM == nil {
- return fmt.Errorf("Extension failed to load. Please reinstall the extension")
- }
+ ext.VMMu.Lock()
+ defer ext.VMMu.Unlock()
- settingsJSON, err := json.Marshal(settings)
- if err != nil {
- return fmt.Errorf("Failed to save settings")
- }
-
- script := fmt.Sprintf(`
- (function() {
- var settings = %s;
- if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
- try {
- extension.initialize(settings);
- return { success: true };
- } catch (e) {
- return { success: false, error: e.toString() };
- }
- }
- return { success: true, message: 'no initialize function' };
- })()
- `, string(settingsJSON))
-
- result, err := ext.VM.RunString(script)
- if err != nil {
- ext.Error = fmt.Sprintf("initialize failed: %v", err)
- ext.Enabled = false
- GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
+ if err := ensureRuntimeReadyLocked(ext, false); err != nil {
return err
}
-
- if result != nil && !goja.IsUndefined(result) {
- exported := result.Export()
- if resultMap, ok := exported.(map[string]interface{}); ok {
- if success, ok := resultMap["success"].(bool); ok && !success {
- errMsg := "unknown error"
- if e, ok := resultMap["error"].(string); ok {
- errMsg = e
- }
- ext.Error = errMsg
- ext.Enabled = false
- GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
- return fmt.Errorf("initialize failed: %s", errMsg)
- }
- }
- }
-
- GoLog("[Extension] Initialized %s\n", extensionID)
- return nil
+ return initializeExtensionWithSettingsLocked(ext, settings)
}
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
if ext.VM == nil {
return nil
}
-
- script := `
- (function() {
- if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
- try {
- extension.cleanup();
- return { success: true };
- } catch (e) {
- return { success: false, error: e.toString() };
- }
- }
- return { success: true, message: 'no cleanup function' };
- })()
- `
-
- result, err := ext.VM.RunString(script)
- if err != nil {
+ ext.VMMu.Lock()
+ defer ext.VMMu.Unlock()
+ if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
return err
}
-
- if result != nil && !goja.IsUndefined(result) {
- exported := result.Export()
- if resultMap, ok := exported.(map[string]interface{}); ok {
- if success, ok := resultMap["success"].(bool); ok && !success {
- errMsg := "unknown error"
- if e, ok := resultMap["error"].(string); ok {
- errMsg = e
- }
- GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
- return fmt.Errorf("cleanup failed: %s", errMsg)
- }
- }
- }
-
GoLog("[Extension] Cleaned up %s\n", extensionID)
return nil
}
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
- if ext.VM == nil {
- return nil, fmt.Errorf("extension VM not initialized")
+ if err := ext.ensureRuntimeReady(); err != nil {
+ return nil, err
}
if !ext.Enabled {
diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go
index a3e46450..df7467f9 100644
--- a/go_backend/extension_providers.go
+++ b/go_backend/extension_providers.go
@@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
}
}
+func (p *ExtensionProviderWrapper) lockReadyVM() error {
+ vm, err := p.extension.lockReadyVM()
+ if err != nil {
+ return err
+ }
+ p.vm = vm
+ return nil
+}
+
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
if !p.extension.Enabled {
return track, nil
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
+ return track, nil
+ }
defer p.extension.VMMu.Unlock()
trackJSON, err := json.Marshal(track)
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -493,7 +510,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
const ExtDownloadTimeout = DownloadTimeout
-func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
+func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
}
@@ -501,9 +518,18 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return &ExtDownloadResult{
+ Success: false,
+ ErrorMessage: err.Error(),
+ ErrorType: "init_error",
+ }, nil
+ }
defer p.extension.VMMu.Unlock()
+ if p.extension.runtime != nil {
+ p.extension.runtime.setActiveDownloadItemID(itemID)
+ defer p.extension.runtime.clearActiveDownloadItemID()
+ }
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) > 0 {
@@ -1106,7 +1132,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID)
}
- result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
+ result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
@@ -1334,7 +1360,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
StartItemProgress(req.ItemID)
}
- result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
+ result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
if req.ItemID != "" {
normalized := float64(percent) / 100.0
if normalized < 0 {
@@ -1626,8 +1652,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
if options == nil {
@@ -1707,8 +1734,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -1792,8 +1820,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack)
@@ -1862,8 +1891,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return &PostProcessResult{Success: false, Error: err.Error()}, nil
+ }
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
@@ -1924,8 +1954,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return &PostProcessResult{Success: false, Error: err.Error()}, nil
+ }
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
@@ -2182,8 +2213,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
-
- p.extension.VMMu.Lock()
+ if err := p.lockReadyVM(); err != nil {
+ return nil, err
+ }
defer p.extension.VMMu.Unlock()
// Use global variables to avoid JS injection issues with special characters in track/artist names
diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go
index 58068a42..7f2c0848 100644
--- a/go_backend/extension_runtime.go
+++ b/go_backend/extension_runtime.go
@@ -90,6 +90,9 @@ type ExtensionRuntime struct {
dataDir string
vm *goja.Runtime
+ activeDownloadMu sync.RWMutex
+ activeDownloadItemID string
+
storageMu sync.RWMutex
storageCache map[string]interface{}
storageLoaded bool
@@ -139,6 +142,24 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
return runtime
}
+func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
+ r.activeDownloadMu.Lock()
+ defer r.activeDownloadMu.Unlock()
+ r.activeDownloadItemID = strings.TrimSpace(itemID)
+}
+
+func (r *ExtensionRuntime) clearActiveDownloadItemID() {
+ r.activeDownloadMu.Lock()
+ defer r.activeDownloadMu.Unlock()
+ r.activeDownloadItemID = ""
+}
+
+func (r *ExtensionRuntime) getActiveDownloadItemID() string {
+ r.activeDownloadMu.RLock()
+ defer r.activeDownloadMu.RUnlock()
+ return r.activeDownloadItemID
+}
+
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g.
diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go
index 92312c98..9e442aea 100644
--- a/go_backend/extension_runtime_file.go
+++ b/go_backend/extension_runtime_file.go
@@ -205,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
defer out.Close()
contentLength := resp.ContentLength
+ activeItemID := r.getActiveDownloadItemID()
+ if activeItemID != "" && contentLength > 0 {
+ SetItemBytesTotal(activeItemID, contentLength)
+ }
+
+ var progressWriter interface{ Write([]byte) (int, error) } = out
+ if activeItemID != "" {
+ progressWriter = NewItemProgressWriter(out, activeItemID)
+ }
var written int64
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
- nw, ew := out.Write(buf[0:nr])
+ nw, ew := progressWriter.Write(buf[0:nr])
if nw < 0 || nr < nw {
nw = 0
if ew == nil {
@@ -220,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
}
written += int64(nw)
if ew != nil {
+ if ew == ErrDownloadCancelled {
+ return r.vm.ToValue(map[string]interface{}{
+ "success": false,
+ "error": "download cancelled",
+ })
+ }
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to write file: %v", ew),
diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go
index 22ac2d0a..3a2c479b 100644
--- a/go_backend/extension_store.go
+++ b/go_backend/extension_store.go
@@ -21,7 +21,7 @@ const (
CategoryIntegration = "integration"
)
-type StoreExtension struct {
+type storeExtension struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
@@ -41,7 +41,7 @@ type StoreExtension struct {
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
}
-func (e *StoreExtension) getDisplayName() string {
+func (e *storeExtension) getDisplayName() string {
if e.DisplayName != "" {
return e.DisplayName
}
@@ -51,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
return e.Name
}
-func (e *StoreExtension) getDownloadURL() string {
+func (e *storeExtension) getDownloadURL() string {
if e.DownloadURL != "" {
return e.DownloadURL
}
return e.DownloadURLAlt
}
-func (e *StoreExtension) getIconURL() string {
+func (e *storeExtension) getIconURL() string {
if e.IconURL != "" {
return e.IconURL
}
return e.IconURLAlt
}
-func (e *StoreExtension) getMinAppVersion() string {
+func (e *storeExtension) getMinAppVersion() string {
if e.MinAppVersion != "" {
return e.MinAppVersion
}
return e.MinAppVersionAlt
}
-type StoreRegistry struct {
+type storeRegistry struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
- Extensions []StoreExtension `json:"extensions"`
+ Extensions []storeExtension `json:"extensions"`
}
-type StoreExtensionResponse struct {
+type storeExtensionResponse struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"`
}
-func (e *StoreExtension) ToResponse() StoreExtensionResponse {
- return StoreExtensionResponse{
+func (e *storeExtension) toResponse() storeExtensionResponse {
+ resp := storeExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
@@ -108,25 +108,30 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
DownloadURL: e.getDownloadURL(),
IconURL: e.getIconURL(),
Category: e.Category,
- Tags: e.Tags,
Downloads: e.Downloads,
UpdatedAt: e.UpdatedAt,
MinAppVersion: e.getMinAppVersion(),
}
+
+ if len(e.Tags) > 0 {
+ resp.Tags = append([]string(nil), e.Tags...)
+ }
+
+ return resp
}
-type ExtensionStore struct {
+type extensionStore struct {
registryURL string
cacheDir string
- cache *StoreRegistry
+ cache *storeRegistry
cacheMu sync.RWMutex
cacheTime time.Time
cacheTTL time.Duration
}
var (
- extensionStore *ExtensionStore
- extensionStoreMu sync.Mutex
+ globalExtensionStore *extensionStore
+ extensionStoreMu sync.Mutex
)
const (
@@ -134,24 +139,24 @@ const (
cacheFileName = "store_cache.json"
)
-func InitExtensionStore(cacheDir string) *ExtensionStore {
+func initExtensionStore(cacheDir string) *extensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
- if extensionStore == nil {
- extensionStore = &ExtensionStore{
+ if globalExtensionStore == nil {
+ globalExtensionStore = &extensionStore{
registryURL: "", // No default - user must provide a registry URL
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
- extensionStore.loadDiskCache()
+ globalExtensionStore.loadDiskCache()
}
- return extensionStore
+ return globalExtensionStore
}
// SetRegistryURL updates the registry URL and clears the in-memory cache
// so the next fetch will use the new URL. Disk cache is also cleared.
-func (s *ExtensionStore) SetRegistryURL(registryURL string) {
+func (s *extensionStore) setRegistryURL(registryURL string) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
@@ -173,19 +178,19 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) {
}
// GetRegistryURL returns the currently configured registry URL.
-func (s *ExtensionStore) GetRegistryURL() string {
+func (s *extensionStore) getRegistryURL() string {
s.cacheMu.RLock()
defer s.cacheMu.RUnlock()
return s.registryURL
}
-func GetExtensionStore() *ExtensionStore {
+func getExtensionStore() *extensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
- return extensionStore
+ return globalExtensionStore
}
-func (s *ExtensionStore) loadDiskCache() {
+func (s *extensionStore) loadDiskCache() {
if s.cacheDir == "" {
return
}
@@ -197,7 +202,7 @@ func (s *ExtensionStore) loadDiskCache() {
}
var cacheData struct {
- Registry StoreRegistry `json:"registry"`
+ Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}
@@ -210,13 +215,13 @@ func (s *ExtensionStore) loadDiskCache() {
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
}
-func (s *ExtensionStore) saveDiskCache() {
+func (s *extensionStore) saveDiskCache() {
if s.cacheDir == "" || s.cache == nil {
return
}
cacheData := struct {
- Registry StoreRegistry `json:"registry"`
+ Registry storeRegistry `json:"registry"`
CacheTime int64 `json:"cache_time"`
}{
Registry: *s.cache,
@@ -232,11 +237,10 @@ func (s *ExtensionStore) saveDiskCache() {
os.WriteFile(cachePath, data, 0644)
}
-func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
+func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
- // Check if a registry URL has been configured
if s.registryURL == "" {
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
}
@@ -276,7 +280,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return nil, fmt.Errorf("failed to read registry: %w", err)
}
- var registry StoreRegistry
+ var registry storeRegistry
if err := json.Unmarshal(body, ®istry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err)
}
@@ -289,8 +293,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return ®istry, nil
}
-func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
- registry, err := s.FetchRegistry(false)
+func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
+ registry, err := s.fetchRegistry(forceRefresh)
if err != nil {
return nil, err
}
@@ -304,29 +308,32 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
}
}
- result := make([]StoreExtensionResponse, len(registry.Extensions))
- for i, ext := range registry.Extensions {
- resp := ext.ToResponse()
+ LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
+ result := make([]storeExtensionResponse, 0, len(registry.Extensions))
+ for i := range registry.Extensions {
+ ext := ®istry.Extensions[i]
+ resp := ext.toResponse()
if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true
resp.InstalledVersion = installedVersion
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
}
- result[i] = resp
+ result = append(result, resp)
}
+ LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
return result, nil
}
-func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
- registry, err := s.FetchRegistry(false)
+func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
+ registry, err := s.fetchRegistry(false)
if err != nil {
return err
}
- var ext *StoreExtension
+ var ext *storeExtension
for _, e := range registry.Extensions {
if e.ID == extensionID {
ext = &e
@@ -378,7 +385,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
// the GitHub API to discover the default branch, then converted to the raw URL
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
-func ResolveRegistryURL(input string) (string, error) {
+func resolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", fmt.Errorf("registry URL is empty")
@@ -389,7 +396,6 @@ func ResolveRegistryURL(input string) (string, error) {
return input, nil
}
- // Try to match https://github.com//[/...]
const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently.
@@ -460,7 +466,7 @@ func requireHTTPSURL(rawURL string, context string) error {
return nil
}
-func (s *ExtensionStore) GetCategories() []string {
+func (s *extensionStore) getCategories() []string {
return []string{
CategoryMetadata,
CategoryDownload,
@@ -470,8 +476,8 @@ func (s *ExtensionStore) GetCategories() []string {
}
}
-func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
- extensions, err := s.GetExtensionsWithStatus()
+func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
+ extensions, err := s.getExtensionsWithStatus(false)
if err != nil {
return nil, err
}
@@ -480,7 +486,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return extensions, nil
}
- var result []StoreExtensionResponse
+ result := make([]storeExtensionResponse, 0, len(extensions))
queryLower := toLower(query)
for _, ext := range extensions {
@@ -493,7 +499,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
- // Check tags
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
@@ -513,7 +518,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return result, nil
}
-func (s *ExtensionStore) ClearCache() {
+func (s *extensionStore) clearCache() {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
diff --git a/go_backend/go.mod b/go_backend/go.mod
index fe67fe7e..7b1e584a 100644
--- a/go_backend/go.mod
+++ b/go_backend/go.mod
@@ -12,6 +12,7 @@ require (
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
golang.org/x/net v0.50.0
+ golang.org/x/text v0.34.0
)
require (
@@ -24,6 +25,5 @@ require (
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
- golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
diff --git a/go_backend/go.sum b/go_backend/go.sum
index 50e29433..3b71ae9b 100644
--- a/go_backend/go.sum
+++ b/go_backend/go.sum
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
-github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
@@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
-golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
-golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
-golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
-golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
-golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
-golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
-golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
-golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
-golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
-golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
-golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
diff --git a/go_backend/httputil.go b/go_backend/httputil.go
index 05e4af8f..b3a4c752 100644
--- a/go_backend/httputil.go
+++ b/go_backend/httputil.go
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue
}
- // Check for ISP blocking via HTTP status codes
- // Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
- // Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
@@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil
}
-// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil {
@@ -553,7 +549,6 @@ func extractDomain(rawURL string) string {
return "unknown"
}
-// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go
index 4b09deb7..c3a90670 100644
--- a/go_backend/httputil_utls.go
+++ b/go_backend/httputil_utls.go
@@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
resp, err := sharedClient.Do(req)
if err == nil {
- // Check for Cloudflare challenge page (403 with specific markers)
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
@@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil
}
- // Check if error might be TLS-related (Cloudflare blocking)
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go
index ee8874d1..c56b57d9 100644
--- a/go_backend/library_scan.go
+++ b/go_backend/library_scan.go
@@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
continue
}
- // Skip audio files that are referenced by a .cue sheet
- // (they will be represented by the cue sheet's track entries instead)
if cueReferencedAudioFiles[filePath] {
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
@@ -557,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
return string(jsonBytes), nil
}
-// ScanLibraryFolderIncremental performs an incremental scan of the library folder
-// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
-// Only files that are new or have changed modification time will be scanned
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
@@ -637,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
- // Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo
skippedCount := 0
existingCueTrackModTimes := make(map[string]int64)
@@ -653,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
- // For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
- // CUE file exists in DB via virtual paths; check if modTime changed
if f.modTime == cueTrackModTime {
skippedCount++
} else {
@@ -675,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
var deletedPaths []string
for existingPath := range existingFiles {
- // For CUE virtual paths (e.g. "/path/album.cue#track01"),
- // check if the base .cue file still exists on disk
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
- continue // Base .cue file still exists, not deleted
+ continue
}
- // Base CUE file is gone, mark virtual path as deleted
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
@@ -713,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
- // Track audio files referenced by .cue sheets to avoid duplicates (incremental)
cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan {
@@ -748,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
ext := strings.ToLower(filepath.Ext(f.path))
- // Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path]
@@ -773,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
continue
}
- // Skip audio files referenced by .cue sheets
if cueReferencedAudioFilesInc[f.path] {
continue
}
diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go
index 60d3aa85..ef3d855e 100644
--- a/go_backend/lyrics.go
+++ b/go_backend/lyrics.go
@@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
return
}
- // Validate provider names
validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true,
@@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
GoLog("[Lyrics] Provider order set to: %v\n", valid)
}
-// GetLyricsProviderOrder returns the current lyrics provider order.
func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
@@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string {
return result
}
-// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
@@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
return opts
}
-// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
normalized := normalizeLyricsFetchOptions(opts)
@@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
)
}
-// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
func GetLyricsFetchOptions() LyricsFetchOptions {
lyricsFetchOptionsMu.RLock()
defer lyricsFetchOptionsMu.RUnlock()
@@ -667,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
- // Cascade through all configured built-in providers
for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName)
diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go
index 1c4970cf..23a24d75 100644
--- a/go_backend/qobuz.go
+++ b/go_backend/qobuz.go
@@ -262,26 +262,35 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string {
return fmt.Sprintf("%s (%s)", title, version)
}
+var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`)
+
+func qobuzUpscaleImageURL(url string) string {
+ if url == "" {
+ return ""
+ }
+ return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg")
+}
+
func qobuzTrackAlbumImage(track *QobuzTrack) string {
if track == nil {
return ""
}
- return qobuzFirstNonEmpty(
+ return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
track.Album.Image.Large,
track.Album.Image.Small,
track.Album.Image.Thumbnail,
- )
+ ))
}
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
if album == nil {
return ""
}
- return qobuzFirstNonEmpty(
+ return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
album.Image.Large,
album.Image.Small,
album.Image.Thumbnail,
- )
+ ))
}
func qobuzTrackArtistID(track *QobuzTrack) string {
@@ -936,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
for i := range album.Tracks.Items {
- tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i]))
+ track := &album.Tracks.Items[i]
+ track.Album.ID = album.ID
+ track.Album.Title = album.Title
+ track.Album.ReleaseDate = album.ReleaseDateOriginal
+ track.Album.Image = qobuzImageSet{
+ Thumbnail: album.Image.Thumbnail,
+ Small: album.Image.Small,
+ Large: album.Image.Large,
+ }
+ track.Album.TracksCount = album.TracksCount
+ tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
}
return &AlbumResponsePayload{
diff --git a/go_backend/tidal.go b/go_backend/tidal.go
index 34de563e..d9738e41 100644
--- a/go_backend/tidal.go
+++ b/go_backend/tidal.go
@@ -1015,13 +1015,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
for _, item := range itemsModule.PagedList.Items {
track := item.Item
- if track.Album.ID == 0 {
- track.Album.ID = headerModule.Album.ID
- track.Album.Title = headerModule.Album.Title
- track.Album.Cover = headerModule.Album.Cover
- track.Album.ReleaseDate = headerModule.Album.ReleaseDate
- track.Album.URL = headerModule.Album.URL
- }
+ track.Album.ID = headerModule.Album.ID
+ track.Album.Title = headerModule.Album.Title
+ track.Album.Cover = headerModule.Album.Cover
+ track.Album.ReleaseDate = headerModule.Album.ReleaseDate
+ track.Album.URL = headerModule.Album.URL
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
}
diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go
index cecf462d..b5089065 100644
--- a/go_backend/title_match_utils.go
+++ b/go_backend/title_match_utils.go
@@ -24,11 +24,9 @@ func normalizeLooseTitle(title string) string {
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte(' ')
- // Treat common separators as spaces.
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
- // Drop other punctuation/symbols (including emoji) for loose matching.
}
}
@@ -59,7 +57,6 @@ func normalizeLooseArtistName(name string) string {
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
- // Drop remaining punctuation/symbols for loose artist matching.
}
}
@@ -102,13 +99,11 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String()
}
-// ==================== Shared Track Verification ====================
-
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
- Duration int // seconds
+ Duration int
}
// trackMatchesRequest checks whether a resolved track from a provider matches
diff --git a/go_backend/youtube.go b/go_backend/youtube.go
deleted file mode 100644
index 3bb65f5a..00000000
--- a/go_backend/youtube.go
+++ /dev/null
@@ -1,750 +0,0 @@
-package gobackend
-
-import (
- "bufio"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "sync"
-)
-
-type YouTubeDownloader struct {
- client *http.Client
- apiURL string
- mu sync.Mutex
-}
-
-const spotubeBaseURL = "https://spotubedl.com"
-
-var (
- globalYouTubeDownloader *YouTubeDownloader
- youtubeDownloaderOnce sync.Once
-)
-
-type YouTubeQuality string
-
-const (
- YouTubeQualityOpus320 YouTubeQuality = "opus_320"
- YouTubeQualityOpus256 YouTubeQuality = "opus_256"
- YouTubeQualityOpus128 YouTubeQuality = "opus_128"
- YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
- YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
- YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
-)
-
-var (
- youtubeOpusSupportedBitrates = []int{128, 256, 320}
- youtubeMp3SupportedBitrates = []int{128, 256, 320}
-)
-
-type CobaltRequest struct {
- URL string `json:"url"`
- AudioBitrate string `json:"audioBitrate,omitempty"`
- AudioFormat string `json:"audioFormat,omitempty"`
- DownloadMode string `json:"downloadMode,omitempty"`
- FilenameStyle string `json:"filenameStyle,omitempty"`
- DisableMetadata bool `json:"disableMetadata,omitempty"`
-}
-
-type CobaltResponse struct {
- Status string `json:"status"`
- URL string `json:"url,omitempty"`
- Filename string `json:"filename,omitempty"`
- Error *struct {
- Code string `json:"code"`
- Context *struct {
- Service string `json:"service,omitempty"`
- Limit int `json:"limit,omitempty"`
- } `json:"context,omitempty"`
- } `json:"error,omitempty"`
-}
-
-type YouTubeDownloadResult struct {
- FilePath string
- Title string
- Artist string
- Album string
- ReleaseDate string
- TrackNumber int
- DiscNumber int
- ISRC string
- Format string // "opus" or "mp3"
- Bitrate int
- LyricsLRC string
- CoverData []byte
-}
-
-func NewYouTubeDownloader() *YouTubeDownloader {
- youtubeDownloaderOnce.Do(func() {
- globalYouTubeDownloader = &YouTubeDownloader{
- client: NewHTTPClientWithTimeout(DownloadTimeout),
- apiURL: "https://api.qwkuns.me",
- }
- })
- return globalYouTubeDownloader
-}
-
-func extractBitrateFromQuality(raw string, defaultBitrate int) int {
- parts := strings.FieldsFunc(raw, func(r rune) bool {
- return (r < '0' || r > '9')
- })
- for i := len(parts) - 1; i >= 0; i-- {
- part := parts[i]
- if part == "" {
- continue
- }
- if parsed, err := strconv.Atoi(part); err == nil {
- return parsed
- }
- }
- return defaultBitrate
-}
-
-func nearestSupportedBitrate(value int, supported []int) int {
- nearest := supported[0]
- nearestDistance := absInt(value - nearest)
-
- for _, option := range supported[1:] {
- distance := absInt(value - option)
- // On tie prefer higher quality.
- if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
- nearest = option
- nearestDistance = distance
- }
- }
-
- return nearest
-}
-
-func absInt(value int) int {
- if value < 0 {
- return -value
- }
- return value
-}
-
-func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
- normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
-
- if strings.HasPrefix(normalizedRaw, "opus") {
- parsed := extractBitrateFromQuality(normalizedRaw, 256)
- finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
- return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
- }
-
- if strings.HasPrefix(normalizedRaw, "mp3") {
- parsed := extractBitrateFromQuality(normalizedRaw, 320)
- finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
- return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
- }
-
- // Backward compatibility for legacy symbolic values.
- switch normalizedRaw {
- case "opus_256", "opus256", "opus":
- return "opus", 256, YouTubeQualityOpus256
- case "opus_320", "opus320":
- return "opus", 320, YouTubeQualityOpus320
- case "opus_128", "opus128":
- return "opus", 128, YouTubeQualityOpus128
- case "mp3_320", "mp3320", "mp3", "":
- return "mp3", 320, YouTubeQualityMP3320
- case "mp3_256", "mp3256":
- return "mp3", 256, YouTubeQualityMP3256
- case "mp3_128", "mp3128":
- return "mp3", 128, YouTubeQualityMP3128
- default:
- return "mp3", 320, YouTubeQualityMP3320
- }
-}
-
-func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
- query := fmt.Sprintf("%s %s", artistName, trackName)
- searchQuery := url.QueryEscape(query)
-
- GoLog("[YouTube] Search query: %s\n", query)
-
- youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
-
- return youtubeMusicURL, nil
-}
-
-func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
- y.mu.Lock()
- defer y.mu.Unlock()
-
- audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
- audioBitrate := strconv.Itoa(bitrate)
-
- // Try SpotubeDL first (primary)
- var spotubeErr error
- videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
- if extractErr == nil {
- GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
- videoID, audioFormat, audioBitrate)
-
- resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
- if err == nil {
- return resp, nil
- }
- spotubeErr = err
- GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
- } else {
- GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
- }
-
- // Fallback: direct Cobalt API (api.qwkuns.me)
- cobaltURL := toYouTubeMusicURL(youtubeURL)
- GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
- cobaltURL, audioFormat, audioBitrate)
-
- resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
- if err != nil {
- if spotubeErr != nil {
- return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
- }
- return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
- }
-
- return resp, nil
-}
-
-func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
- reqBody := CobaltRequest{
- URL: videoURL,
- AudioFormat: audioFormat,
- AudioBitrate: audioBitrate,
- DownloadMode: "audio",
- FilenameStyle: "basic",
- DisableMetadata: true,
- }
-
- jsonData, err := json.Marshal(reqBody)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal request: %w", err)
- }
-
- req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
-
- resp, err := DoRequestWithUserAgent(y.client, req)
- if err != nil {
- return nil, fmt.Errorf("cobalt API request failed: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
-
- if resp.StatusCode != 200 {
- return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
- }
-
- var cobaltResp CobaltResponse
- if err := json.Unmarshal(body, &cobaltResp); err != nil {
- return nil, fmt.Errorf("failed to parse response: %w", err)
- }
-
- if cobaltResp.Status == "error" && cobaltResp.Error != nil {
- return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
- }
-
- if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
- return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
- }
-
- if cobaltResp.URL == "" {
- return nil, fmt.Errorf("no download URL in response")
- }
-
- GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
- return &cobaltResp, nil
-}
-
-// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
-// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
-func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
- engines := []string{"v1"}
- if strings.EqualFold(audioFormat, "mp3") {
- engines = append(engines, "v3", "v2")
- }
- var lastErr error
-
- for _, engine := range engines {
- resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
- if err == nil {
- return resp, nil
- }
- lastErr = err
- GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
- }
-
- if lastErr == nil {
- lastErr = fmt.Errorf("no SpotubeDL engine available")
- }
- return nil, lastErr
-}
-
-func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
- apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
- spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
-
- GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
-
- req, err := http.NewRequest("GET", apiURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Accept", "application/json")
-
- resp, err := DoRequestWithUserAgent(y.client, req)
- if err != nil {
- return nil, fmt.Errorf("spotubedl request failed: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
-
- if resp.StatusCode != 200 {
- return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
- }
-
- var result struct {
- URL string `json:"url"`
- Status string `json:"status"`
- Error string `json:"error"`
- Message string `json:"message"`
- Filename string `json:"filename"`
- }
- if err := json.Unmarshal(body, &result); err != nil {
- return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
- }
-
- downloadURL := strings.TrimSpace(result.URL)
- if downloadURL == "" {
- if result.Error != "" {
- return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
- }
- if result.Message != "" {
- return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
- }
- return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
- }
-
- if strings.HasPrefix(downloadURL, "/") {
- downloadURL = spotubeBaseURL + downloadURL
- }
-
- if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
- return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
- }
-
- filename := strings.TrimSpace(result.Filename)
- if filename == "" {
- if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
- if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
- if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
- filename = decodedFilename
- } else {
- filename = queryFilename
- }
- }
- }
- }
-
- GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
- return &CobaltResponse{
- Status: "tunnel",
- URL: downloadURL,
- Filename: filename,
- }, nil
-}
-
-func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
- ctx := context.Background()
-
- if itemID != "" {
- StartItemProgress(itemID)
- defer CompleteItemProgress(itemID)
- ctx = initDownloadCancel(itemID)
- defer clearDownloadCancel(itemID)
- }
-
- if isDownloadCancelled(itemID) {
- return ErrDownloadCancelled
- }
-
- req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := DoRequestWithUserAgent(y.client, req)
- if err != nil {
- if isDownloadCancelled(itemID) {
- return ErrDownloadCancelled
- }
- return fmt.Errorf("download request failed: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != 200 {
- return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
- }
-
- expectedSize := resp.ContentLength
- if expectedSize > 0 && itemID != "" {
- SetItemBytesTotal(itemID, expectedSize)
- }
-
- out, err := openOutputForWrite(outputPath, outputFD)
- if err != nil {
- return fmt.Errorf("failed to create output file: %w", err)
- }
-
- bufWriter := bufio.NewWriterSize(out, 256*1024)
-
- var written int64
- if itemID != "" {
- progressWriter := NewItemProgressWriter(bufWriter, itemID)
- written, err = io.Copy(progressWriter, resp.Body)
- } else {
- written, err = io.Copy(bufWriter, resp.Body)
- }
-
- flushErr := bufWriter.Flush()
- closeErr := out.Close()
-
- if err != nil {
- cleanupOutputOnError(outputPath, outputFD)
- if isDownloadCancelled(itemID) {
- return ErrDownloadCancelled
- }
- return fmt.Errorf("download interrupted: %w", err)
- }
- if flushErr != nil {
- cleanupOutputOnError(outputPath, outputFD)
- return fmt.Errorf("failed to flush buffer: %w", flushErr)
- }
- if closeErr != nil {
- cleanupOutputOnError(outputPath, outputFD)
- return fmt.Errorf("failed to close file: %w", closeErr)
- }
-
- if expectedSize > 0 && written != expectedSize {
- cleanupOutputOnError(outputPath, outputFD)
- return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
- }
-
- GoLog("[YouTube] Download completed: %d bytes written\n", written)
-
- return nil
-}
-
-func BuildYouTubeSearchURL(trackName, artistName string) string {
- query := fmt.Sprintf("%s %s official audio", artistName, trackName)
- return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
-}
-
-func BuildYouTubeWatchURL(videoID string) string {
- return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
-}
-
-func isYouTubeVideoID(s string) bool {
- if len(s) != 11 {
- return false
- }
- for _, c := range s {
- if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
- return false
- }
- }
- return true
-}
-
-func IsYouTubeURL(urlStr string) bool {
- lower := strings.ToLower(urlStr)
- return strings.Contains(lower, "youtube.com") ||
- strings.Contains(lower, "youtu.be") ||
- strings.Contains(lower, "music.youtube.com")
-}
-
-// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
-// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
-func toYouTubeMusicURL(rawURL string) string {
- videoID, err := ExtractYouTubeVideoID(rawURL)
- if err != nil {
- return rawURL
- }
- return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
-}
-
-func ExtractYouTubeVideoID(urlStr string) (string, error) {
- if strings.Contains(urlStr, "youtu.be/") {
- parts := strings.Split(urlStr, "youtu.be/")
- if len(parts) >= 2 {
- videoID := strings.Split(parts[1], "?")[0]
- videoID = strings.Split(videoID, "&")[0]
- return strings.TrimSpace(videoID), nil
- }
- }
-
- parsed, err := url.Parse(urlStr)
- if err != nil {
- return "", fmt.Errorf("invalid URL: %w", err)
- }
-
- if v := parsed.Query().Get("v"); v != "" {
- return v, nil
- }
-
- if strings.Contains(parsed.Path, "/embed/") {
- parts := strings.Split(parsed.Path, "/embed/")
- if len(parts) >= 2 {
- return strings.Split(parts[1], "/")[0], nil
- }
- }
-
- if strings.Contains(parsed.Path, "/v/") {
- parts := strings.Split(parsed.Path, "/v/")
- if len(parts) >= 2 {
- return strings.Split(parts[1], "/")[0], nil
- }
- }
-
- return "", fmt.Errorf("could not extract video ID from URL")
-}
-
-// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
-// to find a track by artist + title. It filters for tracks only (not videos,
-// albums, or playlists) and returns the YouTube Music watch URL for the first
-// matching track, or "" if nothing was found.
-func searchYouTubeMusicViaExtension(artistName, trackName string) string {
- extManager := GetExtensionManager()
- searchProviders := extManager.GetSearchProviders()
-
- // Find the ytmusic-spotiflac extension
- var ytProvider *ExtensionProviderWrapper
- for _, p := range searchProviders {
- if p.extension.ID == "ytmusic-spotiflac" {
- ytProvider = p
- break
- }
- }
- if ytProvider == nil {
- GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
- return ""
- }
-
- query := strings.TrimSpace(artistName + " " + trackName)
- if query == "" {
- return ""
- }
-
- GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
- results, err := ytProvider.CustomSearch(query, map[string]interface{}{
- "filter": "tracks",
- })
- if err != nil {
- GoLog("[YouTube] YT Music extension search failed: %v\n", err)
- return ""
- }
-
- // Find the first track result (item_type == "track" with a valid video ID)
- for _, track := range results {
- if track.ItemType != "" && track.ItemType != "track" {
- continue
- }
- videoID := strings.TrimSpace(track.ID)
- if videoID == "" {
- continue
- }
- if isYouTubeVideoID(videoID) {
- return BuildYouTubeWatchURL(videoID)
- }
- }
-
- GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
- return ""
-}
-
-func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
- downloader := NewYouTubeDownloader()
-
- format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
-
- // URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
- var youtubeURL string
- var lookupErr error
-
- // SpotifyID might actually be a YouTube video ID (from YT Music extension)
- if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
- youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
- GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
- }
-
- // Try YT Music extension search first (if installed) - more accurate, tracks only
- if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
- youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
- if youtubeURL != "" {
- GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
- }
- }
-
- // Fallback: Try Spotify ID via SongLink
- if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
- GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
- songlink := NewSongLinkClient()
- youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
- if lookupErr != nil {
- GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
- } else {
- GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
- }
- }
-
- // Fallback: Try Deezer ID via SongLink
- if youtubeURL == "" && req.DeezerID != "" {
- GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
- songlink := NewSongLinkClient()
- youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
- if lookupErr != nil {
- GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
- } else {
- GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
- }
- }
-
- // Fallback: Try ISRC via SongLink
- if youtubeURL == "" && req.ISRC != "" {
- GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
- songlink := NewSongLinkClient()
- availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
- if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
- youtubeURL = availability.YouTubeURL
- GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
- } else if isrcErr != nil {
- GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
- }
- }
-
- // Cobalt requires direct video URLs, not search URLs
- if youtubeURL == "" {
- return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
- }
-
- GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
-
- cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
- if err != nil {
- return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
- }
-
- ext := ".mp3"
- if format == "opus" {
- ext = ".opus"
- }
-
- // Some SpotubeDL engines may return a different output container than requested.
- // Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
- if cobaltResp != nil && cobaltResp.Filename != "" {
- lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
- switch {
- case strings.HasSuffix(lowerName, ".mp3"):
- ext = ".mp3"
- format = "mp3"
- case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
- ext = ".opus"
- format = "opus"
- }
- }
-
- filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
- "title": req.TrackName,
- "artist": req.ArtistName,
- "album": req.AlbumName,
- "track": req.TrackNumber,
- "year": extractYear(req.ReleaseDate),
- "date": req.ReleaseDate,
- "disc": req.DiscNumber,
- })
- filename = sanitizeFilename(filename) + ext
-
- var outputPath string
- isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
- if isSafOutput {
- outputPath = strings.TrimSpace(req.OutputPath)
- if outputPath == "" && isFDOutput(req.OutputFD) {
- outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
- }
- } else {
- outputPath = req.OutputDir + "/" + filename
- }
-
- GoLog("[YouTube] Downloading to: %s\n", outputPath)
-
- var parallelResult *ParallelDownloadResult
- if req.EmbedLyrics || req.CoverURL != "" {
- GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
- parallelResult = FetchCoverAndLyricsParallel(
- req.CoverURL,
- req.EmbedMaxQualityCover,
- req.SpotifyID,
- req.TrackName,
- req.ArtistName,
- req.EmbedLyrics,
- int64(req.DurationMS),
- )
- }
-
- if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
- return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
- }
-
- lyricsLRC := ""
- var coverData []byte
- if parallelResult != nil {
- if parallelResult.LyricsLRC != "" {
- lyricsLRC = parallelResult.LyricsLRC
- GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
- }
- if parallelResult.CoverData != nil {
- coverData = parallelResult.CoverData
- GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
- }
- }
-
- return YouTubeDownloadResult{
- FilePath: outputPath,
- Title: req.TrackName,
- Artist: req.ArtistName,
- Album: req.AlbumName,
- ReleaseDate: req.ReleaseDate,
- TrackNumber: req.TrackNumber,
- DiscNumber: req.DiscNumber,
- ISRC: req.ISRC,
- Format: format,
- Bitrate: bitrate,
- LyricsLRC: lyricsLRC,
- CoverData: coverData,
- }, nil
-}
diff --git a/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go
deleted file mode 100644
index e0f2ebbf..00000000
--- a/go_backend/youtube_quality_test.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package gobackend
-
-import "testing"
-
-func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
- format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
- if format != "opus" {
- t.Fatalf("expected opus format, got %s", format)
- }
- if bitrate != 128 {
- t.Fatalf("expected 128 bitrate, got %d", bitrate)
- }
- if normalized != YouTubeQualityOpus128 {
- t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
- }
-}
-
-func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
- format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
- if format != "mp3" {
- t.Fatalf("expected mp3 format, got %s", format)
- }
- if bitrate != 256 {
- t.Fatalf("expected 256 bitrate, got %d", bitrate)
- }
- if normalized != YouTubeQualityMP3256 {
- t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
- }
-}
-
-func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
- _, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
- if opusBitrate != 320 {
- t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
- }
-
- _, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
- if mp3Bitrate != 128 {
- t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
- }
-}
-
-func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
- format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
- if format != "opus" {
- t.Fatalf("expected opus format, got %s", format)
- }
- if bitrate != 320 {
- t.Fatalf("expected 320 bitrate, got %d", bitrate)
- }
- if normalized != YouTubeQualityOpus320 {
- t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
- }
-}
diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart
index 94e71f92..3ff69130 100644
--- a/lib/constants/app_info.dart
+++ b/lib/constants/app_info.dart
@@ -3,14 +3,14 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
- static const String version = '3.9.0';
- static const String buildNumber = '115';
+ static const String version = '4.1.1';
+ static const String buildNumber = '118';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
static String get displayVersion => kDebugMode ? 'Internal' : version;
- static const String appName = 'SpotiFLAC';
+ static const String appName = 'SpotiFLAC Mobile';
static const String copyright = '© 2026 SpotiFLAC';
static const String mobileAuthor = 'zarzet';
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 2fd44b83..64eee3fb 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -1432,6 +1432,66 @@ abstract class AppLocalizations {
/// **'Playlists'**
String get searchPlaylists;
+ /// Bottom sheet title for search sort options
+ ///
+ /// In en, this message translates to:
+ /// **'Sort Results'**
+ String get searchSortTitle;
+
+ /// Sort option - default API order
+ ///
+ /// In en, this message translates to:
+ /// **'Default'**
+ String get searchSortDefault;
+
+ /// Sort option - title ascending
+ ///
+ /// In en, this message translates to:
+ /// **'Title (A-Z)'**
+ String get searchSortTitleAZ;
+
+ /// Sort option - title descending
+ ///
+ /// In en, this message translates to:
+ /// **'Title (Z-A)'**
+ String get searchSortTitleZA;
+
+ /// Sort option - artist ascending
+ ///
+ /// In en, this message translates to:
+ /// **'Artist (A-Z)'**
+ String get searchSortArtistAZ;
+
+ /// Sort option - artist descending
+ ///
+ /// In en, this message translates to:
+ /// **'Artist (Z-A)'**
+ String get searchSortArtistZA;
+
+ /// Sort option - shortest duration first
+ ///
+ /// In en, this message translates to:
+ /// **'Duration (Shortest)'**
+ String get searchSortDurationShort;
+
+ /// Sort option - longest duration first
+ ///
+ /// In en, this message translates to:
+ /// **'Duration (Longest)'**
+ String get searchSortDurationLong;
+
+ /// Sort option - oldest release first
+ ///
+ /// In en, this message translates to:
+ /// **'Release Date (Oldest)'**
+ String get searchSortDateOldest;
+
+ /// Sort option - newest release first
+ ///
+ /// In en, this message translates to:
+ /// **'Release Date (Newest)'**
+ String get searchSortDateNewest;
+
/// Tooltip - play button
///
/// In en, this message translates to:
@@ -2662,24 +2722,6 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'**
String get qualityNote;
- /// Note for YouTube service explaining lossy-only quality
- ///
- /// In en, this message translates to:
- /// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
- String get youtubeQualityNote;
-
- /// Title for YouTube Opus bitrate setting
- ///
- /// In en, this message translates to:
- /// **'YouTube Opus Bitrate'**
- String get youtubeOpusBitrateTitle;
-
- /// Title for YouTube MP3 bitrate setting
- ///
- /// In en, this message translates to:
- /// **'YouTube MP3 Bitrate'**
- String get youtubeMp3BitrateTitle;
-
/// Setting - show quality picker
///
/// In en, this message translates to:
@@ -2860,6 +2902,18 @@ abstract class AppLocalizations {
/// **'Artist/Album/ and Artist/Singles/'**
String get albumFolderArtistAlbumSinglesSubtitle;
+ /// Album folder option with singles directly in artist folder
+ ///
+ /// In en, this message translates to:
+ /// **'Artist / Album (Singles flat)'**
+ String get albumFolderArtistAlbumFlat;
+
+ /// Folder structure example for flat singles
+ ///
+ /// In en, this message translates to:
+ /// **'Artist/Album/ and Artist/song.flac'**
+ String get albumFolderArtistAlbumFlatSubtitle;
+
/// Button - delete selected tracks
///
/// In en, this message translates to:
@@ -5084,6 +5138,168 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Empty only'**
String get editMetadataSelectEmpty;
+
+ /// Header for active downloads section with count
+ ///
+ /// In en, this message translates to:
+ /// **'Downloading ({count})'**
+ String queueDownloadingCount(int count);
+
+ /// Header label for downloaded items section in library
+ ///
+ /// In en, this message translates to:
+ /// **'Downloaded'**
+ String get queueDownloadedHeader;
+
+ /// Shown while filter results are being computed
+ ///
+ /// In en, this message translates to:
+ /// **'Filtering...'**
+ String get queueFilteringIndicator;
+
+ /// Track count label with plural support
+ ///
+ /// In en, this message translates to:
+ /// **'{count, plural, =1{1 track} other{{count} tracks}}'**
+ String queueTrackCount(int count);
+
+ /// Album count label with plural support
+ ///
+ /// In en, this message translates to:
+ /// **'{count, plural, =1{1 album} other{{count} albums}}'**
+ String queueAlbumCount(int count);
+
+ /// Empty state title when no album downloads exist
+ ///
+ /// In en, this message translates to:
+ /// **'No album downloads'**
+ String get queueEmptyAlbums;
+
+ /// Empty state subtitle for album downloads
+ ///
+ /// In en, this message translates to:
+ /// **'Download multiple tracks from an album to see them here'**
+ String get queueEmptyAlbumsSubtitle;
+
+ /// Empty state title when no single track downloads exist
+ ///
+ /// In en, this message translates to:
+ /// **'No single downloads'**
+ String get queueEmptySingles;
+
+ /// Empty state subtitle for single track downloads
+ ///
+ /// In en, this message translates to:
+ /// **'Single track downloads will appear here'**
+ String get queueEmptySinglesSubtitle;
+
+ /// Empty state title when download history is empty
+ ///
+ /// In en, this message translates to:
+ /// **'No download history'**
+ String get queueEmptyHistory;
+
+ /// Empty state subtitle for download history
+ ///
+ /// In en, this message translates to:
+ /// **'Downloaded tracks will appear here'**
+ String get queueEmptyHistorySubtitle;
+
+ /// Shown when all playlists are selected in selection mode
+ ///
+ /// In en, this message translates to:
+ /// **'All playlists selected'**
+ String get selectionAllPlaylistsSelected;
+
+ /// Hint shown in playlist selection mode
+ ///
+ /// In en, this message translates to:
+ /// **'Tap playlists to select'**
+ String get selectionTapPlaylistsToSelect;
+
+ /// Hint shown when no playlists are selected for deletion
+ ///
+ /// In en, this message translates to:
+ /// **'Select playlists to delete'**
+ String get selectionSelectPlaylistsToDelete;
+
+ /// Title for audio analysis section
+ ///
+ /// In en, this message translates to:
+ /// **'Audio Quality Analysis'**
+ String get audioAnalysisTitle;
+
+ /// Description for audio analysis tap-to-analyze prompt
+ ///
+ /// In en, this message translates to:
+ /// **'Verify lossless quality with spectrum analysis'**
+ String get audioAnalysisDescription;
+
+ /// Loading text while analyzing audio
+ ///
+ /// In en, this message translates to:
+ /// **'Analyzing audio...'**
+ String get audioAnalysisAnalyzing;
+
+ /// Sample rate metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Sample Rate'**
+ String get audioAnalysisSampleRate;
+
+ /// Bit depth metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Bit Depth'**
+ String get audioAnalysisBitDepth;
+
+ /// Channels metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Channels'**
+ String get audioAnalysisChannels;
+
+ /// Duration metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Duration'**
+ String get audioAnalysisDuration;
+
+ /// Nyquist frequency metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Nyquist'**
+ String get audioAnalysisNyquist;
+
+ /// File size metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Size'**
+ String get audioAnalysisFileSize;
+
+ /// Dynamic range metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Dynamic Range'**
+ String get audioAnalysisDynamicRange;
+
+ /// Peak amplitude metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Peak'**
+ String get audioAnalysisPeak;
+
+ /// RMS level metric label
+ ///
+ /// In en, this message translates to:
+ /// **'RMS'**
+ String get audioAnalysisRms;
+
+ /// Total samples metric label
+ ///
+ /// In en, this message translates to:
+ /// **'Samples'**
+ String get audioAnalysisSamples;
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart
index d56bb267..15888ae5 100644
--- a/lib/l10n/app_localizations_de.dart
+++ b/lib/l10n/app_localizations_de.dart
@@ -772,6 +772,36 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get searchPlaylists => 'Playlisten';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Abspielen';
@@ -1449,16 +1479,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote =>
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
- @override
- String get youtubeQualityNote =>
- 'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
@@ -1558,6 +1578,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Künstler/Album/ und Künstler/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
@@ -2995,4 +3022,106 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 594df94e..210342d2 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -759,6 +759,36 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,4 +2990,106 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart
index 4dbe9944..f39ba881 100644
--- a/lib/l10n/app_localizations_es.dart
+++ b/lib/l10n/app_localizations_es.dart
@@ -759,6 +759,36 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -4334,16 +4463,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
String get qualityNote =>
'La calidad real depende de la disponibilidad de la pista del servicio';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart
index e80c2576..f0e783a5 100644
--- a/lib/l10n/app_localizations_fr.dart
+++ b/lib/l10n/app_localizations_fr.dart
@@ -761,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Play';
@@ -1427,16 +1457,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1534,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2964,4 +2991,106 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart
index a387be72..d36edd4b 100644
--- a/lib/l10n/app_localizations_hi.dart
+++ b/lib/l10n/app_localizations_hi.dart
@@ -759,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2962,4 +2989,106 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart
index 12ee009f..45d363fa 100644
--- a/lib/l10n/app_localizations_id.dart
+++ b/lib/l10n/app_localizations_id.dart
@@ -762,6 +762,36 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get searchPlaylists => 'Playlist';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Putar';
@@ -1433,16 +1463,6 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
- @override
- String get youtubeQualityNote =>
- 'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.';
-
- @override
- String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus';
-
- @override
- String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube';
-
@override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1541,6 +1561,13 @@ class AppLocalizationsId extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artis/Album/ dan Artis/Single/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -2972,4 +2999,106 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart
index b0b279ec..3d64d7d0 100644
--- a/lib/l10n/app_localizations_ja.dart
+++ b/lib/l10n/app_localizations_ja.dart
@@ -754,6 +754,36 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get searchPlaylists => 'プレイリスト';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => '再生';
@@ -1414,16 +1444,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
-
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1519,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => '選択済みを削除';
@@ -2949,4 +2976,106 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart
index 97230226..69036d66 100644
--- a/lib/l10n/app_localizations_ko.dart
+++ b/lib/l10n/app_localizations_ko.dart
@@ -741,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get searchPlaylists => '재생목록들';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => '재생';
@@ -1405,16 +1435,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1512,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2942,4 +2969,106 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart
index 533e9b57..1ee2d0b9 100644
--- a/lib/l10n/app_localizations_nl.dart
+++ b/lib/l10n/app_localizations_nl.dart
@@ -759,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2962,4 +2989,106 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index 64890c8c..444a91e0 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -759,6 +759,36 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -4331,16 +4460,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get qualityNote =>
'A qualidade real depende da faixa que estiver disponível no serviço';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart
index d56adafe..fcd6c96b 100644
--- a/lib/l10n/app_localizations_ru.dart
+++ b/lib/l10n/app_localizations_ru.dart
@@ -773,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get searchPlaylists => 'Плейлисты';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Воспроизвести';
@@ -1450,16 +1480,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
- @override
- String get youtubeQualityNote =>
- 'YouTube обеспечивает только звук с потерями(Lossy).';
-
- @override
- String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
-
- @override
- String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
-
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1561,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Исполнитель/Альбом и Исполнитель/Сингл/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -3022,4 +3049,106 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart
index d4edbff2..42e95073 100644
--- a/lib/l10n/app_localizations_tr.dart
+++ b/lib/l10n/app_localizations_tr.dart
@@ -764,6 +764,36 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get searchPlaylists => 'Çalma Listeleri';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Oynat';
@@ -1431,16 +1461,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1538,6 +1558,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2968,4 +2995,106 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart
index c478d001..9b379784 100644
--- a/lib/l10n/app_localizations_zh.dart
+++ b/lib/l10n/app_localizations_zh.dart
@@ -759,6 +759,36 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get searchPlaylists => 'Playlists';
+ @override
+ String get searchSortTitle => 'Sort Results';
+
+ @override
+ String get searchSortDefault => 'Default';
+
+ @override
+ String get searchSortTitleAZ => 'Title (A-Z)';
+
+ @override
+ String get searchSortTitleZA => 'Title (Z-A)';
+
+ @override
+ String get searchSortArtistAZ => 'Artist (A-Z)';
+
+ @override
+ String get searchSortArtistZA => 'Artist (Z-A)';
+
+ @override
+ String get searchSortDurationShort => 'Duration (Shortest)';
+
+ @override
+ String get searchSortDurationLong => 'Duration (Longest)';
+
+ @override
+ String get searchSortDateOldest => 'Release Date (Oldest)';
+
+ @override
+ String get searchSortDateNewest => 'Release Date (Newest)';
+
@override
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
+ @override
+ String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
+
+ @override
+ String get albumFolderArtistAlbumFlatSubtitle =>
+ 'Artist/Album/ and Artist/song.flac';
+
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get editMetadataSelectEmpty => 'Empty only';
+
+ @override
+ String queueDownloadingCount(int count) {
+ return 'Downloading ($count)';
+ }
+
+ @override
+ String get queueDownloadedHeader => 'Downloaded';
+
+ @override
+ String get queueFilteringIndicator => 'Filtering...';
+
+ @override
+ String queueTrackCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String queueAlbumCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count albums',
+ one: '1 album',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get queueEmptyAlbums => 'No album downloads';
+
+ @override
+ String get queueEmptyAlbumsSubtitle =>
+ 'Download multiple tracks from an album to see them here';
+
+ @override
+ String get queueEmptySingles => 'No single downloads';
+
+ @override
+ String get queueEmptySinglesSubtitle =>
+ 'Single track downloads will appear here';
+
+ @override
+ String get queueEmptyHistory => 'No download history';
+
+ @override
+ String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
+
+ @override
+ String get selectionAllPlaylistsSelected => 'All playlists selected';
+
+ @override
+ String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
+
+ @override
+ String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
+
+ @override
+ String get audioAnalysisTitle => 'Audio Quality Analysis';
+
+ @override
+ String get audioAnalysisDescription =>
+ 'Verify lossless quality with spectrum analysis';
+
+ @override
+ String get audioAnalysisAnalyzing => 'Analyzing audio...';
+
+ @override
+ String get audioAnalysisSampleRate => 'Sample Rate';
+
+ @override
+ String get audioAnalysisBitDepth => 'Bit Depth';
+
+ @override
+ String get audioAnalysisChannels => 'Channels';
+
+ @override
+ String get audioAnalysisDuration => 'Duration';
+
+ @override
+ String get audioAnalysisNyquist => 'Nyquist';
+
+ @override
+ String get audioAnalysisFileSize => 'Size';
+
+ @override
+ String get audioAnalysisDynamicRange => 'Dynamic Range';
+
+ @override
+ String get audioAnalysisPeak => 'Peak';
+
+ @override
+ String get audioAnalysisRms => 'RMS';
+
+ @override
+ String get audioAnalysisSamples => 'Samples';
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -4297,16 +4426,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -6703,16 +6822,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get qualityNote =>
'Actual quality depends on track availability from the service';
- @override
- String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
-
- @override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
-
- @override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
-
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb
index 3dce0557..151c6a8d 100644
--- a/lib/l10n/arb/app_de.arb
+++ b/lib/l10n/arb/app_de.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Qualität vor Download fragen",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb
index 7e882ab2..4107d8b9 100644
--- a/lib/l10n/arb/app_en.arb
+++ b/lib/l10n/arb/app_en.arb
@@ -999,6 +999,46 @@
"@searchPlaylists": {
"description": "Search result category - playlists"
},
+ "searchSortTitle": "Sort Results",
+ "@searchSortTitle": {
+ "description": "Bottom sheet title for search sort options"
+ },
+ "searchSortDefault": "Default",
+ "@searchSortDefault": {
+ "description": "Sort option - default API order"
+ },
+ "searchSortTitleAZ": "Title (A-Z)",
+ "@searchSortTitleAZ": {
+ "description": "Sort option - title ascending"
+ },
+ "searchSortTitleZA": "Title (Z-A)",
+ "@searchSortTitleZA": {
+ "description": "Sort option - title descending"
+ },
+ "searchSortArtistAZ": "Artist (A-Z)",
+ "@searchSortArtistAZ": {
+ "description": "Sort option - artist ascending"
+ },
+ "searchSortArtistZA": "Artist (Z-A)",
+ "@searchSortArtistZA": {
+ "description": "Sort option - artist descending"
+ },
+ "searchSortDurationShort": "Duration (Shortest)",
+ "@searchSortDurationShort": {
+ "description": "Sort option - shortest duration first"
+ },
+ "searchSortDurationLong": "Duration (Longest)",
+ "@searchSortDurationLong": {
+ "description": "Sort option - longest duration first"
+ },
+ "searchSortDateOldest": "Release Date (Oldest)",
+ "@searchSortDateOldest": {
+ "description": "Sort option - oldest release first"
+ },
+ "searchSortDateNewest": "Release Date (Newest)",
+ "@searchSortDateNewest": {
+ "description": "Sort option - newest release first"
+ },
"tooltipPlay": "Play",
"@tooltipPlay": {
"description": "Tooltip - play button"
@@ -1869,18 +1909,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
@@ -2001,6 +2029,14 @@
"@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example"
},
+ "albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)",
+ "@albumFolderArtistAlbumFlat": {
+ "description": "Album folder option with singles directly in artist folder"
+ },
+ "albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac",
+ "@albumFolderArtistAlbumFlatSubtitle": {
+ "description": "Folder structure example for flat singles"
+ },
"downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
@@ -3903,5 +3939,129 @@
"editMetadataSelectEmpty": "Empty only",
"@editMetadataSelectEmpty": {
"description": "Button to select only fields that are currently empty"
+ },
+
+ "queueDownloadingCount": "Downloading ({count})",
+ "@queueDownloadingCount": {
+ "description": "Header for active downloads section with count",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "queueDownloadedHeader": "Downloaded",
+ "@queueDownloadedHeader": {
+ "description": "Header label for downloaded items section in library"
+ },
+ "queueFilteringIndicator": "Filtering...",
+ "@queueFilteringIndicator": {
+ "description": "Shown while filter results are being computed"
+ },
+ "queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}",
+ "@queueTrackCount": {
+ "description": "Track count label with plural support",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}",
+ "@queueAlbumCount": {
+ "description": "Album count label with plural support",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "queueEmptyAlbums": "No album downloads",
+ "@queueEmptyAlbums": {
+ "description": "Empty state title when no album downloads exist"
+ },
+ "queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here",
+ "@queueEmptyAlbumsSubtitle": {
+ "description": "Empty state subtitle for album downloads"
+ },
+ "queueEmptySingles": "No single downloads",
+ "@queueEmptySingles": {
+ "description": "Empty state title when no single track downloads exist"
+ },
+ "queueEmptySinglesSubtitle": "Single track downloads will appear here",
+ "@queueEmptySinglesSubtitle": {
+ "description": "Empty state subtitle for single track downloads"
+ },
+ "queueEmptyHistory": "No download history",
+ "@queueEmptyHistory": {
+ "description": "Empty state title when download history is empty"
+ },
+ "queueEmptyHistorySubtitle": "Downloaded tracks will appear here",
+ "@queueEmptyHistorySubtitle": {
+ "description": "Empty state subtitle for download history"
+ },
+ "selectionAllPlaylistsSelected": "All playlists selected",
+ "@selectionAllPlaylistsSelected": {
+ "description": "Shown when all playlists are selected in selection mode"
+ },
+ "selectionTapPlaylistsToSelect": "Tap playlists to select",
+ "@selectionTapPlaylistsToSelect": {
+ "description": "Hint shown in playlist selection mode"
+ },
+ "selectionSelectPlaylistsToDelete": "Select playlists to delete",
+ "@selectionSelectPlaylistsToDelete": {
+ "description": "Hint shown when no playlists are selected for deletion"
+ },
+ "audioAnalysisTitle": "Audio Quality Analysis",
+ "@audioAnalysisTitle": {
+ "description": "Title for audio analysis section"
+ },
+ "audioAnalysisDescription": "Verify lossless quality with spectrum analysis",
+ "@audioAnalysisDescription": {
+ "description": "Description for audio analysis tap-to-analyze prompt"
+ },
+ "audioAnalysisAnalyzing": "Analyzing audio...",
+ "@audioAnalysisAnalyzing": {
+ "description": "Loading text while analyzing audio"
+ },
+ "audioAnalysisSampleRate": "Sample Rate",
+ "@audioAnalysisSampleRate": {
+ "description": "Sample rate metric label"
+ },
+ "audioAnalysisBitDepth": "Bit Depth",
+ "@audioAnalysisBitDepth": {
+ "description": "Bit depth metric label"
+ },
+ "audioAnalysisChannels": "Channels",
+ "@audioAnalysisChannels": {
+ "description": "Channels metric label"
+ },
+ "audioAnalysisDuration": "Duration",
+ "@audioAnalysisDuration": {
+ "description": "Duration metric label"
+ },
+ "audioAnalysisNyquist": "Nyquist",
+ "@audioAnalysisNyquist": {
+ "description": "Nyquist frequency metric label"
+ },
+ "audioAnalysisFileSize": "Size",
+ "@audioAnalysisFileSize": {
+ "description": "File size metric label"
+ },
+ "audioAnalysisDynamicRange": "Dynamic Range",
+ "@audioAnalysisDynamicRange": {
+ "description": "Dynamic range metric label"
+ },
+ "audioAnalysisPeak": "Peak",
+ "@audioAnalysisPeak": {
+ "description": "Peak amplitude metric label"
+ },
+ "audioAnalysisRms": "RMS",
+ "@audioAnalysisRms": {
+ "description": "RMS level metric label"
+ },
+ "audioAnalysisSamples": "Samples",
+ "@audioAnalysisSamples": {
+ "description": "Total samples metric label"
}
}
diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb
index dab6880d..e622a44c 100644
--- a/lib/l10n/arb/app_es_ES.arb
+++ b/lib/l10n/arb/app_es_ES.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Preguntar antes de descargar",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb
index 2fdf0477..5d2f1cae 100644
--- a/lib/l10n/arb/app_fr.arb
+++ b/lib/l10n/arb/app_fr.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb
index 0eeebf16..f2568821 100644
--- a/lib/l10n/arb/app_hi.arb
+++ b/lib/l10n/arb/app_hi.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb
index eba93ff1..1cd89fba 100644
--- a/lib/l10n/arb/app_id.arb
+++ b/lib/l10n/arb/app_id.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "Bitrate YouTube Opus",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "Kecepatan Bit MP3 YouTube",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb
index 44674a04..ad71c3f3 100644
--- a/lib/l10n/arb/app_ja.arb
+++ b/lib/l10n/arb/app_ja.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "ダウンロード前に確認する",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb
index b872ef5c..1bec37ba 100644
--- a/lib/l10n/arb/app_ko.arb
+++ b/lib/l10n/arb/app_ko.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb
index 438519c3..ad97b6a3 100644
--- a/lib/l10n/arb/app_nl.arb
+++ b/lib/l10n/arb/app_nl.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb
index 9c7e843d..3cc94df2 100644
--- a/lib/l10n/arb/app_pt_PT.arb
+++ b/lib/l10n/arb/app_pt_PT.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb
index 4a1ffc71..e9373f9c 100644
--- a/lib/l10n/arb/app_ru.arb
+++ b/lib/l10n/arb/app_ru.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb
index 53ce4fb7..1afcb84d 100644
--- a/lib/l10n/arb/app_tr.arb
+++ b/lib/l10n/arb/app_tr.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb
index db6943ab..ff232550 100644
--- a/lib/l10n/arb/app_zh_CN.arb
+++ b/lib/l10n/arb/app_zh_CN.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb
index cf4f7b4a..598bb415 100644
--- a/lib/l10n/arb/app_zh_TW.arb
+++ b/lib/l10n/arb/app_zh_TW.arb
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"description": "Note about quality availability"
},
- "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
- "@youtubeQualityNote": {
- "description": "Note for YouTube service explaining lossy-only quality"
- },
- "youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
- "@youtubeOpusBitrateTitle": {
- "description": "Title for YouTube Opus bitrate setting"
- },
- "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
- "@youtubeMp3BitrateTitle": {
- "description": "Title for YouTube MP3 bitrate setting"
- },
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {
"description": "Setting - show quality picker"
diff --git a/lib/main.dart b/lib/main.dart
index 89d5d93c..56c94ae2 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
if (settings.localLibraryPath.isEmpty) return;
if (settings.localLibraryAutoScan == 'off') return;
- // Don't start a scan if one is already running.
final libraryState = ref.read(localLibraryProvider);
if (libraryState.isScanning) return;
- // Determine cooldown based on auto-scan mode.
final now = DateTime.now();
final prefs = await SharedPreferences.getInstance();
final lastScanned = readLocalLibraryLastScannedAt(prefs);
@@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
}
}
- // All checks passed -- start an incremental scan.
final iosBookmark = settings.localLibraryBookmark;
ref
.read(localLibraryProvider.notifier)
diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart
index db55029b..d8ac97cb 100644
--- a/lib/models/download_item.dart
+++ b/lib/models/download_item.dart
@@ -12,13 +12,7 @@ enum DownloadStatus {
skipped,
}
-enum DownloadErrorType {
- unknown,
- notFound,
- rateLimit,
- network,
- permission,
-}
+enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
@JsonSerializable()
class DownloadItem {
@@ -28,7 +22,8 @@ class DownloadItem {
final DownloadStatus status;
final double progress;
final double speedMBps;
- final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
+ final int bytesReceived; // Bytes downloaded so far
+ final int bytesTotal; // Total bytes when the server provides content length
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
@@ -44,6 +39,7 @@ class DownloadItem {
this.progress = 0.0,
this.speedMBps = 0.0,
this.bytesReceived = 0,
+ this.bytesTotal = 0,
this.filePath,
this.error,
this.errorType,
@@ -60,6 +56,7 @@ class DownloadItem {
double? progress,
double? speedMBps,
int? bytesReceived,
+ int? bytesTotal,
String? filePath,
String? error,
DownloadErrorType? errorType,
@@ -75,6 +72,7 @@ class DownloadItem {
progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived,
+ bytesTotal: bytesTotal ?? this.bytesTotal,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
errorType: errorType ?? this.errorType,
@@ -86,7 +84,7 @@ class DownloadItem {
String get errorMessage {
if (error == null) return '';
-
+
switch (errorType) {
case DownloadErrorType.notFound:
return 'Song not found on any service';
diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart
index 961e6d6d..7aee835c 100644
--- a/lib/models/download_item.g.dart
+++ b/lib/models/download_item.g.dart
@@ -16,6 +16,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem(
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
+ bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -33,6 +34,7 @@ Map _$DownloadItemToJson(DownloadItem instance) =>
'progress': instance.progress,
'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived,
+ 'bytesTotal': instance.bytesTotal,
'filePath': instance.filePath,
'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
diff --git a/lib/models/settings.dart b/lib/models/settings.dart
index c2f2eed6..63f87abe 100644
--- a/lib/models/settings.dart
+++ b/lib/models/settings.dart
@@ -42,10 +42,6 @@ class AppSettings {
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
- final int
- youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
- final int
- youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
@@ -121,8 +117,6 @@ class AppSettings {
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
- this.youtubeOpusBitrate = 256,
- this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
@@ -189,8 +183,6 @@ class AppSettings {
String? locale,
String? lyricsMode,
String? tidalHighFormat,
- int? youtubeOpusBitrate,
- int? youtubeMp3Bitrate,
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
@@ -257,8 +249,6 @@ class AppSettings {
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
- youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
- youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart
index 914e224f..d78bc81e 100644
--- a/lib/models/settings.g.dart
+++ b/lib/models/settings.g.dart
@@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings(
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
- youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
- youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
@@ -125,8 +123,6 @@ Map _$AppSettingsToJson(
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
- 'youtubeOpusBitrate': instance.youtubeOpusBitrate,
- 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart
index a457b3d2..123a0c97 100644
--- a/lib/providers/download_queue_provider.dart
+++ b/lib/providers/download_queue_provider.dart
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -262,8 +263,14 @@ class DownloadHistoryState {
class DownloadHistoryNotifier extends Notifier {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
+ static const int _orphanCleanupMaxPerLaunch = 80;
static const int _audioMetadataBackfillMaxPerLaunch = 24;
- static const _startupMaintenanceDelay = Duration(seconds: 2);
+ static const _startupMaintenanceDelay = Duration(seconds: 4);
+ static const _startupMaintenanceStepGap = Duration(milliseconds: 250);
+ static const _startupSafRepairCursorKey =
+ 'history_startup_saf_repair_cursor_v1';
+ static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
+ static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
bool _isSafRepairInProgress = false;
@@ -320,20 +327,29 @@ class DownloadHistoryNotifier extends Notifier {
unawaited(
Future.delayed(_startupMaintenanceDelay, () async {
try {
+ final prefs = await SharedPreferences.getInstance();
+
if (Platform.isAndroid) {
await _repairMissingSafEntries(
initialItems,
maxItems: _safRepairMaxPerLaunch,
+ prefs: prefs,
);
+ await Future.delayed(_startupMaintenanceStepGap);
}
- await cleanupOrphanedDownloads();
+ await _cleanupOrphanedDownloadsIncremental(
+ maxItems: _orphanCleanupMaxPerLaunch,
+ prefs: prefs,
+ );
+ await Future.delayed(_startupMaintenanceStepGap);
final currentItems = state.items;
if (currentItems.isNotEmpty) {
await _backfillAudioMetadata(
currentItems,
maxItems: _audioMetadataBackfillMaxPerLaunch,
+ prefs: prefs,
);
}
} catch (e, stack) {
@@ -344,6 +360,30 @@ class DownloadHistoryNotifier extends Notifier {
);
}
+ int _readStartupCursor(SharedPreferences prefs, String key, int totalCount) {
+ if (totalCount <= 0) {
+ return 0;
+ }
+ final cursor = prefs.getInt(key) ?? 0;
+ if (cursor < 0 || cursor >= totalCount) {
+ return 0;
+ }
+ return cursor;
+ }
+
+ Future _writeStartupCursor(
+ SharedPreferences prefs,
+ String key,
+ int nextCursor,
+ int totalCount,
+ ) async {
+ if (totalCount <= 0 || nextCursor <= 0 || nextCursor >= totalCount) {
+ await prefs.remove(key);
+ return;
+ }
+ await prefs.setInt(key, nextCursor);
+ }
+
String _fileNameFromUri(String uri) {
try {
final parsed = Uri.parse(uri);
@@ -357,6 +397,7 @@ class DownloadHistoryNotifier extends Notifier {
Future _repairMissingSafEntries(
List items, {
required int maxItems,
+ required SharedPreferences prefs,
}) async {
if (_isSafRepairInProgress || items.isEmpty) {
return;
@@ -378,22 +419,40 @@ class DownloadHistoryNotifier extends Notifier {
continue;
}
candidateIndexes.add(i);
- if (candidateIndexes.length >= maxItems) break;
}
if (candidateIndexes.isEmpty) {
+ await prefs.remove(_startupSafRepairCursorKey);
+ _isSafRepairInProgress = false;
+ return;
+ }
+
+ final startCursor = _readStartupCursor(
+ prefs,
+ _startupSafRepairCursorKey,
+ candidateIndexes.length,
+ );
+ final endCursor = (startCursor + maxItems).clamp(
+ 0,
+ candidateIndexes.length,
+ );
+ final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor);
+
+ if (selectedIndexes.isEmpty) {
+ await prefs.remove(_startupSafRepairCursorKey);
_isSafRepairInProgress = false;
return;
}
final updatedItems = [...items];
+ final persistedUpdates =