code
- # - ### Header → Header
- # - Escape HTML special chars first
- # - Remove > blockquote prefix
- CHANGELOG=$(echo "$FULL_CHANGELOG" | \
- sed 's/^> //' | \
+ # Convert Markdown to Telegram HTML
+ CHANGELOG=$(cat /tmp/cliff_tg.txt | \
+ sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
+ sed '/^\*\*Full Changelog\*\*/d' | \
+ sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
+ sed 's/ by @[A-Za-z0-9_-]\+//g' | \
+ sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
+ sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
sed 's/&/\&/g' | \
sed 's/\</g' | \
sed 's/>/\>/g' | \
- sed 's/`\([^`]*\)`/\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \
sed 's/^### \(.*\)$/\1<\/b>/g' | \
sed 's/^## \(.*\)$/\1<\/b>/g' | \
- sed 's/^- /• /g' | \
- sed 's/^ - / ◦ /g')
-
- # Take first 2500 characters, then cut at last complete line
+ sed 's/^- /• /g')
+
+ # Truncate for Telegram 4096 char limit
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
-
- # Check if truncated
- FULL_LEN=${#FULL_CHANGELOG}
- if [ $FULL_LEN -gt 2500 ]; then
- CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
- fi
+ echo "$CHANGELOG" > /tmp/changelog.txt
fi
- echo "$CHANGELOG" > /tmp/changelog.txt
- echo "DEBUG: Final changelog:"
+ echo "Telegram changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel
diff --git a/.gitignore b/.gitignore
index e2e2649f..b24e9371 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@ Thumbs.db
# Kiro specs (development only)
.kiro/
+# Design assets (banners, mockups)
+design/
+
# Reference folder (development only)
referensi/
@@ -64,6 +67,7 @@ AGENTS.md
# Temp/misc
nul
+network_requests.txt
# Log files
*.log
@@ -73,3 +77,6 @@ flutter_*.log
# Development tools
tool/
.claude/settings.local.json
+
+# FVM Version Cache
+.fvm/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec8b7254..9916536f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
- SpotFetch metadata fallback integration for Spotify-blocked regions
- - New backend client for `spotify.afkarxyz.fun/api`
+ - New backend client for `sp.afkarxyz.qzz.io/api`
- Automatic fallback in Spotify metadata fetch path when primary source fails
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
- Includes heuristic detection of lyrics stored in Comment fields
@@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
-- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
+- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f1750732..2c1e7b6e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
-3. **Install dependencies**
+3. **Use FVM (Flutter Version: 3.38.1)**
+ ```bash
+ fvm use
+ ```
+
+4. **Install dependencies**
```bash
flutter pub get
```
-4. **Generate code** (for Riverpod, JSON serialization, etc.)
+5. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
-5. **Run the app**
+6. **Set up Go environment (Go Version: 1.25.7)**
+ ```bash
+ cd go_backend
+ mkdir -p ../android/app/libs
+ gomobile init
+ gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
+ cd ..
+ ```
+
+7. **Run the app**
```bash
flutter run
```
diff --git a/README.md b/README.md
index ae6db959..4ad91b55 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,19 @@
-[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
-[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
-[](https://crowdin.com/project/spotiflac-mobile)
-
-
+
+
+
+
+
-Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account required.
-
-
-
+
+
+
+
+
-### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
-
## Screenshots
@@ -24,32 +23,42 @@ Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account r
+
+
+[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
+[](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
+[](https://crowdin.com/project/spotiflac-mobile)
+
+[](https://t.me/spotiflac)
+[](https://t.me/spotiflac_chat)
+
+
+
## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
### Installing Extensions
1. Go to **Store** tab in the app
-2. Browse and install extensions with one tap
-3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
-4. Configure extension settings if needed
-5. Set provider priority in **Settings > Extensions > Provider Priority**
+2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
+3. Browse and install extensions with one tap
+4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
+5. Configure extension settings if needed
+6. Set provider priority in **Settings > Extensions > Provider Priority**
### Developing Extensions
-Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
+Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
## Other project
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
-## Telegram
-
-[](https://t.me/spotiflac)
-[](https://t.me/spotiflac_chat)
-
## FAQ
+**Q: Why does the Store tab ask me to enter a URL?**
+A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
+
**Q: Why is my download failing with "Song not found"?**
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
@@ -68,6 +77,11 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
**Q: Why is download not working in my country?**
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
+**Q: Can I add SpotiFLAC to AltStore or SideStore?**
+A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
+https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
+In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
+
### Want to support SpotiFLAC-Mobile?
@@ -75,6 +89,18 @@ _If this software is useful and brings you value, consider supporting the projec
[](https://ko-fi.com/zarzet)
+## Contributors
+
+Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
+
+
+
+
+
+We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile.
+
+Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started!
+
## API Credits
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [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)
@@ -82,4 +108,4 @@ _If this software is useful and brings you value, consider supporting the projec
> [!TIP]
>
-> **Star Us**, You will receive all release notifications from GitHub without any delay ~
+> **Star Us**, You will receive all release notifications from GitHub without any delay
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 c3fd7b46..fe9b8e07 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt
@@ -115,10 +115,8 @@ class DownloadService : Service() {
* We must call stopSelf() within a few seconds to avoid a crash.
*/
override fun onTimeout(startId: Int, fgsType: Int) {
- // Log the timeout for debugging
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
- // Gracefully stop the service
stopForegroundService()
}
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 da56220d..3fba0054 100644
--- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt
@@ -30,6 +30,7 @@ import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
+import java.security.MessageDigest
import java.util.Locale
class MainActivity: FlutterFragmentActivity() {
@@ -38,7 +39,7 @@ class MainActivity: FlutterFragmentActivity() {
"com.zarz.spotiflac/download_progress_stream"
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/library_scan_progress_stream"
- private val STREAM_POLLING_INTERVAL_MS = 800L
+ private val STREAM_POLLING_INTERVAL_MS = 1200L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
@@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() {
}
}
+ private fun buildStableLibraryId(filePath: String): String {
+ val digest = MessageDigest.getInstance("SHA-1")
+ val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
+ val hex = bytes.joinToString("") { "%02x".format(it) }
+ return "lib_$hex"
+ }
+
data class SafScanProgress(
var totalFiles: Int = 0,
var scannedFiles: Int = 0,
@@ -469,6 +477,32 @@ class MainActivity: FlutterFragmentActivity() {
lastLibraryScanProgressPayload = null
}
+ private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
+ if (snapshotPath.isBlank()) {
+ return "{}"
+ }
+
+ val snapshotFile = File(snapshotPath)
+ if (!snapshotFile.exists()) {
+ return "{}"
+ }
+
+ val result = JSONObject()
+ snapshotFile.forEachLine { line ->
+ if (line.isBlank()) return@forEachLine
+ val separatorIndex = line.indexOf('\t')
+ if (separatorIndex <= 0 || separatorIndex >= line.length - 1) {
+ return@forEachLine
+ }
+ val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
+ val filePath = line.substring(separatorIndex + 1)
+ if (filePath.isNotEmpty()) {
+ result.put(filePath, modTime)
+ }
+ }
+ return result.toString()
+ }
+
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
val obj = JSONObject()
if (treeUriStr.isBlank() || fileName.isBlank()) {
@@ -703,6 +737,80 @@ class MainActivity: FlutterFragmentActivity() {
}
}
+ private fun buildUriDisplayName(
+ uri: Uri,
+ displayNameHint: String? = null,
+ fallbackExt: String? = null,
+ ): String {
+ val explicitName = displayNameHint?.trim().orEmpty()
+ if (explicitName.isNotEmpty()) return explicitName
+
+ val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
+ val uriName = uri.lastPathSegment
+ val resolvedName = (docName ?: uriName ?: "").trim()
+ if (resolvedName.isNotEmpty()) return resolvedName
+
+ val ext = when {
+ fallbackExt.isNullOrBlank().not() -> fallbackExt
+ isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt)
+ else -> ""
+ }
+ return if (ext.isNullOrBlank()) "audio" else "audio$ext"
+ }
+
+ private fun readAudioMetadataFromUri(
+ uri: Uri,
+ displayNameHint: String? = null,
+ fallbackExt: String? = null,
+ ): JSONObject? {
+ val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
+
+ try {
+ contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
+ val directPath = "/proc/self/fd/${pfd.fd}"
+ val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName)
+ if (metadataJson.isNotBlank()) {
+ val obj = JSONObject(metadataJson)
+ if (!obj.has("error")) {
+ return obj
+ }
+ }
+ }
+ } catch (e: Exception) {
+ android.util.Log.d(
+ "SpotiFLAC",
+ "Direct SAF metadata read fallback for $uri: ${e.message}",
+ )
+ }
+
+ val tempPath = try {
+ copyUriToTemp(uri, fallbackExt)
+ } catch (e: Exception) {
+ android.util.Log.w(
+ "SpotiFLAC",
+ "SAF metadata fallback copy failed for $uri: ${e.message}",
+ )
+ null
+ } ?: return null
+
+ try {
+ val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName)
+ if (metadataJson.isBlank()) return null
+ val obj = JSONObject(metadataJson)
+ return if (obj.has("error")) null else obj
+ } catch (e: Exception) {
+ android.util.Log.w(
+ "SpotiFLAC",
+ "SAF metadata temp read failed for $uri: ${e.message}",
+ )
+ return null
+ } finally {
+ try {
+ File(tempPath).delete()
+ } catch (_: Exception) {}
+ }
+ }
+
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
val srcFile = File(srcPath)
if (!srcFile.exists()) return false
@@ -807,6 +915,132 @@ class MainActivity: FlutterFragmentActivity() {
}
}
+ /**
+ * Get the parent DocumentFile directory for a SAF document URI.
+ * The child URI must be a tree-based document URI (e.g. from SAF tree scan).
+ * Returns a DocumentFile that supports findFile() for sibling lookup.
+ */
+ private fun safParentDir(childUri: Uri): DocumentFile? {
+ 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
+
+ val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
+ childUri, parentDocId
+ )
+ return DocumentFile.fromTreeUri(this, parentUri)
+ ?: DocumentFile.fromSingleUri(this, parentUri)
+ } catch (e: Exception) {
+ android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}")
+ return null
+ }
+ }
+
+ /**
+ * Extract the audio filename referenced by a CUE sheet file.
+ * Reads the FILE "name" TYPE line from the .cue text.
+ * Returns just the filename (no path), or null if not found.
+ */
+ private fun extractCueAudioFileName(cueTempPath: String): String? {
+ try {
+ 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("\\")
+ }
+ }
+ } catch (e: Exception) {
+ android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}")
+ }
+ return null
+ }
+
+ private val cueSiblingAudioExtensions = listOf(
+ ".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
+ )
+
+ private fun getSafChildFileLookup(
+ dir: DocumentFile,
+ cache: MutableMap>,
+ ): Map {
+ val dirKey = dir.uri.toString()
+ return cache.getOrPut(dirKey) {
+ try {
+ buildMap {
+ for (child in dir.listFiles()) {
+ if (!child.isFile) continue
+ val childName = child.name?.trim().orEmpty()
+ if (childName.isBlank()) continue
+ put(childName.lowercase(Locale.ROOT), child)
+ }
+ }
+ } catch (e: Exception) {
+ android.util.Log.w(
+ "SpotiFLAC",
+ "Failed to build SAF child lookup for $dirKey: ${e.message}",
+ )
+ emptyMap()
+ }
+ }
+ }
+
+ private fun resolveCueAudioSibling(
+ parentDir: DocumentFile,
+ cueName: String,
+ audioFileName: String?,
+ childLookupCache: MutableMap>,
+ ): DocumentFile? {
+ val childLookup = getSafChildFileLookup(parentDir, childLookupCache)
+
+ val directMatch = audioFileName
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+ ?.substringAfterLast("/")
+ ?.substringAfterLast("\\")
+ ?.lowercase(Locale.ROOT)
+ ?.let(childLookup::get)
+ if (directMatch != null) {
+ return directMatch
+ }
+
+ val cueBaseName = cueName.substringBeforeLast('.').trim()
+ if (cueBaseName.isBlank()) {
+ return null
+ }
+
+ val cueBaseKey = cueBaseName.lowercase(Locale.ROOT)
+ for (ext in cueSiblingAudioExtensions) {
+ childLookup["$cueBaseKey$ext"]?.let { return it }
+ }
+ return null
+ }
+
private fun scanSafTree(treeUriStr: String): String {
if (treeUriStr.isBlank()) return "[]"
@@ -820,9 +1054,12 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
- val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
+ 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>()
var traversalErrors = 0
val queue: ArrayDeque> = ArrayDeque()
@@ -870,7 +1107,9 @@ class MainActivity: FlutterFragmentActivity() {
} else if (child.isFile) {
val name = child.name ?: continue
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
- if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
+ if (ext == "cue") {
+ cueFiles.add(child to dir)
+ } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
audioFiles.add(child to path)
}
}
@@ -885,11 +1124,12 @@ class MainActivity: FlutterFragmentActivity() {
}
}
+ val totalItems = audioFiles.size + cueFiles.size
updateSafScanProgress {
- it.totalFiles = audioFiles.size
+ it.totalFiles = totalItems
}
- if (audioFiles.isEmpty()) {
+ if (audioFiles.isEmpty() && cueFiles.isEmpty()) {
updateSafScanProgress {
it.isComplete = true
it.progressPct = 100.0
@@ -901,12 +1141,123 @@ 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) {
+ if (safScanCancel) {
+ updateSafScanProgress { it.isComplete = true }
+ return "[]"
+ }
+
+ val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
+ updateSafScanProgress { it.currentFile = cueName }
+
+ var tempCuePath: String? = null
+ var tempAudioPath: String? = null
+ try {
+ tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
+ if (tempCuePath == null) {
+ errors++
+ android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}")
+ scanned++
+ continue
+ }
+
+ val audioFileName = extractCueAudioFileName(tempCuePath)
+
+ val audioDoc = resolveCueAudioSibling(
+ parentDir = parentDir,
+ cueName = cueName,
+ audioFileName = audioFileName,
+ childLookupCache = safChildLookupCache,
+ )
+
+ if (audioDoc == null) {
+ android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
+ errors++
+ scanned++
+ 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)
+ val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
+
+ tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
+ if (tempAudioPath == null) {
+ android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName")
+ errors++
+ scanned++
+ 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) {
+ tempAudioFile.renameTo(renamedAudio)
+ tempAudioPath = renamedAudio.absolutePath
+ }
+
+ val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
+
+ val cueResultsJson = Gobackend.scanCueSheetForLibrary(
+ tempCuePath,
+ tempDir,
+ cueDoc.uri.toString(),
+ cueLastModified
+ )
+
+ val cueArray = JSONArray(cueResultsJson)
+ for (j in 0 until cueArray.length()) {
+ results.put(cueArray.getJSONObject(j))
+ }
+
+ android.util.Log.d(
+ "SpotiFLAC",
+ "SAF scan: CUE $cueName -> ${cueArray.length()} tracks"
+ )
+ } catch (e: Exception) {
+ errors++
+ android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}")
+ } finally {
+ try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {}
+ try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
+ }
+
+ scanned++
+ val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
+ updateSafScanProgress {
+ it.scannedFiles = scanned
+ it.errorCount = errors
+ it.progressPct = pct
+ }
+ }
+
+ // --- 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
+ updateSafScanProgress {
+ it.scannedFiles = scanned
+ it.progressPct = pct
+ }
+ continue
+ }
+
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress {
it.currentFile = name
@@ -914,40 +1265,24 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
- val tempPath = try {
- copyUriToTemp(doc.uri, fallbackExt)
- } catch (e: Exception) {
- android.util.Log.w(
- "SpotiFLAC",
- "SAF scan: failed to copy ${doc.uri}: ${e.message}",
- )
- null
- }
- if (tempPath == null) {
+ val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
+ if (metadataObj == null) {
errors++
} else {
try {
- val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
- if (metadataJson.isNotBlank()) {
- val obj = JSONObject(metadataJson)
- val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
- obj.put("filePath", doc.uri.toString())
- obj.put("fileModTime", lastModified)
- results.put(obj)
- } else {
- errors++
- }
+ val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
+ val stableUri = doc.uri.toString()
+ metadataObj.put("id", buildStableLibraryId(stableUri))
+ metadataObj.put("filePath", stableUri)
+ metadataObj.put("fileModTime", lastModified)
+ results.put(metadataObj)
} catch (_: Exception) {
errors++
- } finally {
- try {
- File(tempPath).delete()
- } catch (_: Exception) {}
}
}
scanned++
- val pct = scanned.toDouble() / audioFiles.size.toDouble() * 100.0
+ val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
updateSafScanProgress {
it.scannedFiles = scanned
it.errorCount = errors
@@ -965,6 +1300,8 @@ class MainActivity: FlutterFragmentActivity() {
/**
* Incremental SAF tree scan - only scans new or modified files.
+ * Supports .cue sheets: expands them into virtual track entries and
+ * deduplicates audio files referenced by CUE sheets.
* @param treeUriStr The SAF tree URI to scan
* @param existingFilesJson JSON object mapping file URI -> lastModified timestamp
* @return JSON object with new/changed files and removed URIs
@@ -1007,13 +1344,30 @@ class MainActivity: FlutterFragmentActivity() {
it.currentFile = "Scanning folders..."
}
- val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
+ val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf>() // doc, path, lastModified
+ // CUE files to scan: (cueDoc, parentDir, lastModified)
+ 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
- // Collect all audio files with lastModified
+ // 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]
+ for (key in existingFiles.keys) {
+ val hashIdx = key.indexOf("#track")
+ if (hashIdx > 0) {
+ val baseCueUri = key.substring(0, hashIdx)
+ existingCueVirtualPaths.getOrPut(baseCueUri) { mutableListOf() }.add(key)
+ }
+ }
+
+ // Collect all files with lastModified
val queue: ArrayDeque> = ArrayDeque()
queue.add(root to "")
@@ -1076,7 +1430,27 @@ class MainActivity: FlutterFragmentActivity() {
val name = child.name ?: continue
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
- if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
+
+ if (ext == "cue") {
+ val lastModified = try {
+ 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")) {
val existingModified = existingFiles[uriStr]
val lastModified = try {
child.lastModified()
@@ -1104,13 +1478,14 @@ 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 skippedCount = (totalFiles - audioFiles.size).coerceAtLeast(0)
+ val filesToProcess = audioFiles.size + cueFilesToScan.size
+ val skippedCount = (totalFiles - filesToProcess).coerceAtLeast(0)
updateSafScanProgress {
it.totalFiles = totalFiles
}
- if (audioFiles.isEmpty()) {
+ if (audioFiles.isEmpty() && cueFilesToScan.isEmpty()) {
updateSafScanProgress {
it.isComplete = true
it.scannedFiles = totalFiles
@@ -1128,6 +1503,152 @@ 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) {
+ if (safScanCancel) {
+ updateSafScanProgress { it.isComplete = true }
+ val result = JSONObject()
+ result.put("files", JSONArray())
+ result.put("removedUris", JSONArray())
+ result.put("skippedCount", skippedCount)
+ result.put("totalFiles", totalFiles)
+ result.put("cancelled", true)
+ return result.toString()
+ }
+
+ val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
+ updateSafScanProgress { it.currentFile = cueName }
+
+ var tempCuePath: String? = null
+ var tempAudioPath: String? = null
+ try {
+ // Copy CUE to temp
+ tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
+ if (tempCuePath == null) {
+ errors++
+ android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}")
+ scanned++
+ 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,
+ audioFileName = audioFileName,
+ childLookupCache = safChildLookupCache,
+ )
+
+ if (audioDoc == null) {
+ android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
+ errors++
+ scanned++
+ 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)
+ val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
+
+ tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
+ if (tempAudioPath == null) {
+ android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName")
+ errors++
+ scanned++
+ 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) {
+ tempAudioFile.renameTo(renamedAudio)
+ tempAudioPath = renamedAudio.absolutePath
+ }
+
+ // Call Go to produce library scan entries for each CUE track
+ val cueResultsJson = Gobackend.scanCueSheetForLibrary(
+ tempCuePath,
+ tempDir,
+ cueDoc.uri.toString(),
+ cueLastModified
+ )
+
+ val cueArray = JSONArray(cueResultsJson)
+ 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)
+ }
+ }
+
+ android.util.Log.d(
+ "SpotiFLAC",
+ "SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks"
+ )
+ } catch (e: Exception) {
+ errors++
+ android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}")
+ } finally {
+ try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {}
+ try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
+ }
+
+ scanned++
+ val processed = skippedCount + scanned
+ val pct = if (totalFiles > 0) {
+ processed.toDouble() / totalFiles.toDouble() * 100.0
+ } else {
+ 100.0
+ }
+ updateSafScanProgress {
+ it.scannedFiles = processed
+ it.errorCount = errors
+ it.progressPct = pct
+ }
+ }
+
+ // 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 {
+ tempCue = copyUriToTemp(cueDoc.uri, ".cue")
+ if (tempCue != null) {
+ val audioFileName = extractCueAudioFileName(tempCue)
+ val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
+ val audioDoc = resolveCueAudioSibling(
+ parentDir = parentDir,
+ cueName = cueName,
+ audioFileName = audioFileName,
+ childLookupCache = safChildLookupCache,
+ )
+ if (audioDoc != null) {
+ cueReferencedAudioUris.add(audioDoc.uri.toString())
+ }
+ }
+ } catch (e: Exception) {
+ android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to resolve audio for unchanged CUE: ${e.message}")
+ } finally {
+ try { tempCue?.let { File(it).delete() } } catch (_: Exception) {}
+ }
+ }
+
+ // --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
@@ -1140,6 +1661,22 @@ 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
+ val pct = if (totalFiles > 0) {
+ processed.toDouble() / totalFiles.toDouble() * 100.0
+ } else {
+ 100.0
+ }
+ updateSafScanProgress {
+ it.scannedFiles = processed
+ it.progressPct = pct
+ }
+ continue
+ }
+
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress {
it.currentFile = name
@@ -1147,36 +1684,20 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
- val tempPath = try {
- copyUriToTemp(doc.uri, fallbackExt)
- } catch (e: Exception) {
- android.util.Log.w(
- "SpotiFLAC",
- "SAF incremental scan: failed to copy ${doc.uri}: ${e.message}",
- )
- null
- }
- if (tempPath == null) {
+ val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
+ if (metadataObj == null) {
errors++
} else {
try {
- val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
- if (metadataJson.isNotBlank()) {
- val obj = JSONObject(metadataJson)
- val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
- obj.put("filePath", doc.uri.toString())
- obj.put("fileModTime", safeLastModified)
- obj.put("lastModified", safeLastModified)
- results.put(obj)
- } else {
- errors++
- }
+ val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
+ val stableUri = doc.uri.toString()
+ metadataObj.put("id", buildStableLibraryId(stableUri))
+ metadataObj.put("filePath", stableUri)
+ metadataObj.put("fileModTime", safeLastModified)
+ metadataObj.put("lastModified", safeLastModified)
+ results.put(metadataObj)
} catch (_: Exception) {
errors++
- } finally {
- try {
- File(tempPath).delete()
- } catch (_: Exception) {}
}
}
@@ -1194,6 +1715,9 @@ class MainActivity: FlutterFragmentActivity() {
}
}
+ // Recalculate removedUris now that CUE virtual paths have been registered
+ val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
+
updateSafScanProgress {
it.isComplete = true
it.progressPct = 100.0
@@ -1201,7 +1725,7 @@ class MainActivity: FlutterFragmentActivity() {
val result = JSONObject()
result.put("files", results)
- result.put("removedUris", JSONArray(removedUris))
+ result.put("removedUris", JSONArray(finalRemovedUris))
result.put("skippedCount", skippedCount)
result.put("totalFiles", totalFiles)
return result.toString()
@@ -1455,38 +1979,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
- "getSpotifyMetadata" -> {
- val url = call.argument("url") ?: ""
- val response = withContext(Dispatchers.IO) {
- Gobackend.getSpotifyMetadata(url)
- }
- result.success(response)
- }
- "searchSpotify" -> {
- val query = call.argument("query") ?: ""
- val limit = call.argument("limit") ?: 10
- val response = withContext(Dispatchers.IO) {
- Gobackend.searchSpotify(query, limit.toLong())
- }
- result.success(response)
- }
- "searchSpotifyAll" -> {
- val query = call.argument("query") ?: ""
- val trackLimit = call.argument("track_limit") ?: 15
- val artistLimit = call.argument("artist_limit") ?: 3
- val response = withContext(Dispatchers.IO) {
- Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
- }
- result.success(response)
- }
- "getSpotifyRelatedArtists" -> {
- val artistId = call.argument("artist_id") ?: ""
- val limit = call.argument("limit") ?: 12
- val response = withContext(Dispatchers.IO) {
- Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong())
- }
- result.success(response)
- }
"checkAvailability" -> {
val spotifyId = call.argument("spotify_id") ?: ""
val isrc = call.argument("isrc") ?: ""
@@ -2120,20 +2612,6 @@ class MainActivity: FlutterFragmentActivity() {
"isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning())
}
- "setSpotifyCredentials" -> {
- val clientId = call.argument("client_id") ?: ""
- val clientSecret = call.argument("client_secret") ?: ""
- withContext(Dispatchers.IO) {
- Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
- }
- result.success(null)
- }
- "hasSpotifyCredentials" -> {
- val hasCredentials = withContext(Dispatchers.IO) {
- Gobackend.checkSpotifyCredentials()
- }
- result.success(hasCredentials)
- }
"preWarmTrackCache" -> {
val tracksJson = call.argument("tracks") ?: "[]"
withContext(Dispatchers.IO) {
@@ -2164,6 +2642,28 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
+ // Tidal search API
+ "searchTidalAll" -> {
+ val query = call.argument("query") ?: ""
+ val trackLimit = call.argument("track_limit") ?: 15
+ val artistLimit = call.argument("artist_limit") ?: 2
+ val filter = call.argument("filter") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
+ }
+ result.success(response)
+ }
+ // Qobuz search API
+ "searchQobuzAll" -> {
+ val query = call.argument("query") ?: ""
+ val trackLimit = call.argument("track_limit") ?: 15
+ val artistLimit = call.argument("artist_limit") ?: 2
+ val filter = call.argument("filter") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
+ }
+ result.success(response)
+ }
"getDeezerRelatedArtists" -> {
val artistId = call.argument("artist_id") ?: ""
val limit = call.argument("limit") ?: 12
@@ -2180,6 +2680,22 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
+ "getQobuzMetadata" -> {
+ val resourceType = call.argument("resource_type") ?: ""
+ val resourceId = call.argument("resource_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getQobuzMetadata(resourceType, resourceId)
+ }
+ result.success(response)
+ }
+ "getTidalMetadata" -> {
+ val resourceType = call.argument("resource_type") ?: ""
+ val resourceId = call.argument("resource_id") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getTidalMetadata(resourceType, resourceId)
+ }
+ result.success(response)
+ }
"parseDeezerUrl" -> {
val url = call.argument("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2187,6 +2703,13 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
+ "parseQobuzUrl" -> {
+ val url = call.argument("url") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.parseQobuzURLExport(url)
+ }
+ result.success(response)
+ }
"parseTidalUrl" -> {
val url = call.argument("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2415,6 +2938,15 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
+ "searchTracksWithMetadataProviders" -> {
+ val query = call.argument("query") ?: ""
+ val limit = call.argument("limit") ?: 20
+ val includeExtensions = call.argument("include_extensions") ?: true
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.searchTracksWithMetadataProvidersJSON(query, limit.toLong(), includeExtensions)
+ }
+ result.success(response)
+ }
"enrichTrackWithExtension" -> {
val extensionId = call.argument("extension_id") ?: ""
val trackJson = call.argument("track") ?: "{}"
@@ -2620,6 +3152,25 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
+ "setStoreRegistryUrl" -> {
+ val registryUrl = call.argument("registry_url") ?: ""
+ withContext(Dispatchers.IO) {
+ Gobackend.setStoreRegistryURLJSON(registryUrl)
+ }
+ result.success(null)
+ }
+ "getStoreRegistryUrl" -> {
+ val response = withContext(Dispatchers.IO) {
+ Gobackend.getStoreRegistryURLJSON()
+ }
+ result.success(response)
+ }
+ "clearStoreRegistryUrl" -> {
+ withContext(Dispatchers.IO) {
+ Gobackend.clearStoreRegistryURLJSON()
+ }
+ result.success(null)
+ }
"getStoreExtensions" -> {
val forceRefresh = call.argument("force_refresh") ?: false
val response = withContext(Dispatchers.IO) {
@@ -2695,6 +3246,18 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
+ "scanLibraryFolderIncrementalFromSnapshot" -> {
+ val folderPath = call.argument("folder_path") ?: ""
+ val snapshotPath = call.argument("snapshot_path") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ safScanActive = false
+ Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
+ folderPath,
+ snapshotPath,
+ )
+ }
+ result.success(response)
+ }
"scanSafTree" -> {
val treeUri = call.argument("tree_uri") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2710,6 +3273,16 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
+ "scanSafTreeIncrementalFromSnapshot" -> {
+ val treeUri = call.argument("tree_uri") ?: ""
+ val snapshotPath = call.argument("snapshot_path") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ val existingFilesJson =
+ loadExistingFilesJsonFromSnapshot(snapshotPath)
+ scanSafTreeIncremental(treeUri, existingFilesJson)
+ }
+ result.success(response)
+ }
"getSafFileModTimes" -> {
val uris = call.argument("uris") ?: "[]"
val response = withContext(Dispatchers.IO) {
@@ -2740,13 +3313,10 @@ class MainActivity: FlutterFragmentActivity() {
try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
- val tempPath = copyUriToTemp(uri)
- ?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
- try {
- Gobackend.readAudioMetadataJSON(tempPath)
- } finally {
- try { File(tempPath).delete() } catch (_: Exception) {}
- }
+ val metadata = readAudioMetadataFromUri(uri)
+ ?: return@withContext """{"error":"Failed to read SAF audio metadata"}"""
+ metadata.put("filePath", filePath)
+ metadata.toString()
} else {
Gobackend.readAudioMetadataJSON(filePath)
}
@@ -2756,6 +3326,89 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
+ // CUE Sheet Parsing
+ "parseCueSheet" -> {
+ val cuePath = call.argument("cue_path") ?: ""
+ val audioDir = call.argument("audio_dir") ?: ""
+ val response = withContext(Dispatchers.IO) {
+ try {
+ if (cuePath.startsWith("content://")) {
+ val uri = Uri.parse(cuePath)
+ val tempCuePath = copyUriToTemp(uri, ".cue")
+ ?: 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 ?: ""
+ } catch (_: Exception) { "" }
+ val cueBaseName = cueName.substringBeforeLast('.')
+ if (cueBaseName.isNotBlank()) {
+ val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
+ for (ext in commonExts) {
+ audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
+ if (audioDoc != null) break
+ audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
+ if (audioDoc != null) break
+ }
+ }
+ }
+
+ 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
+ val copiedAudio = copyUriToTemp(audioDoc.uri, fallbackExt)
+ if (copiedAudio != null) {
+ val renamedAudio = File(tempDir, audioName)
+ val copiedFile = File(copiedAudio)
+ if (renamedAudio.absolutePath != copiedFile.absolutePath) {
+ copiedFile.renameTo(renamedAudio)
+ }
+ tempAudioPath = renamedAudio.absolutePath
+ }
+ }
+
+ // 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 {
+ resultJson
+ }
+ } finally {
+ try { File(tempCuePath).delete() } catch (_: Exception) {}
+ try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
+ }
+ } else {
+ Gobackend.parseCueSheet(cuePath, audioDir)
+ }
+ } catch (e: Exception) {
+ """{"error":"${e.message?.replace("\"", "'")}"}"""
+ }
+ }
+ result.success(response)
+ }
else -> result.notImplemented()
}
} catch (e: Exception) {
diff --git a/apps.json b/apps.json
new file mode 100644
index 00000000..fd8aa9e2
--- /dev/null
+++ b/apps.json
@@ -0,0 +1,18 @@
+{
+ "name": "SpotiFLAC Source",
+ "identifier": "com.zarzet.spotiflac.source",
+ "subtitle": "FLAC Downloader for iOS",
+ "apps": [
+ {
+ "name": "SpotiFLAC",
+ "bundleIdentifier": "com.zarzet.spotiflac",
+ "developerName": "zarzet",
+ "version": "3.8.6",
+ "versionDate": "2026-03-16",
+ "downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-ios-unsigned.ipa",
+ "localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
+ "iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
+ "size": 33676960
+ }
+ ]
+}
diff --git a/assets/images/banner-readme-dark.png b/assets/images/banner-readme-dark.png
new file mode 100644
index 00000000..13c40d0b
Binary files /dev/null and b/assets/images/banner-readme-dark.png differ
diff --git a/assets/images/banner-readme-light.png b/assets/images/banner-readme-light.png
new file mode 100644
index 00000000..2aaaba69
Binary files /dev/null and b/assets/images/banner-readme-light.png differ
diff --git a/cliff.toml b/cliff.toml
new file mode 100644
index 00000000..b5680d4b
--- /dev/null
+++ b/cliff.toml
@@ -0,0 +1,103 @@
+# git-cliff configuration for SpotiFLAC Mobile
+# https://git-cliff.org/docs/configuration
+
+[changelog]
+# Template for the changelog body
+body = """
+{%- macro remote_url() -%}
+ https://github.com/zarzet/SpotiFLAC-Mobile
+{%- endmacro -%}
+
+{% if version %}\
+ ## {{ version | trim_start_matches(pat="v") }}
+{% else %}\
+ ## Unreleased
+{% endif %}\
+
+{% for group, commits in commits | group_by(attribute="group") %}
+ ### {{ group | striptags | trim | upper_first }}
+ {% for commit in commits %}
+ - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
+ {{ commit.message | upper_first }}\
+ {% if commit.github.pr_number %} \
+ ([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
+ {% endif %}\
+ {%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
+ {%- endfor %}
+{% endfor %}
+
+{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
+
+ ### New Contributors
+{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
+ * @{{ contributor.username }} made their first contribution
+ {%- if contributor.pr_number %} in \
+ [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
+ {%- endif %}
+{%- endfor %}
+{%- endif -%}
+
+{% if version %}
+ {% if previous.version %}
+ **Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
+ {% endif %}
+{% else -%}
+ {% raw %}\n{% endraw %}
+{% endif %}
+"""
+# Remove leading and trailing whitespace
+trim = true
+
+[git]
+# Parse conventional commits
+conventional_commits = true
+filter_unconventional = true
+
+# Process each line of a commit as an individual commit
+split_commits = false
+
+# Regex for preprocessing the commit messages
+commit_preprocessors = [
+ # Strip conventional commit prefix for cleaner messages
+ # (group header already shows the type)
+]
+
+# Regex for parsing and grouping commits
+commit_parsers = [
+ # Skip noise: translation commits from Crowdin
+ { message = "^New translations", skip = true },
+ { message = "^Update source file", skip = true },
+ # Skip merge commits
+ { message = "^Merge", skip = true },
+ # Skip version bump commits
+ { message = "^v\\d+", skip = true },
+ { message = "^chore: update VirusTotal", skip = true },
+
+ # Group by conventional commit type
+ { message = "^feat", group = "New Features" },
+ { message = "^fix", group = "Bug Fixes" },
+ { message = "^perf", group = "Performance" },
+ { message = "^refactor", group = "Refactoring" },
+ { message = "^doc", group = "Documentation" },
+ { message = "^style", group = "Styling" },
+ { message = "^test", group = "Testing" },
+ { message = "^chore\\(deps\\)", group = "Dependencies" },
+ { message = "^chore\\(l10n\\)", skip = true },
+ { message = "^chore|^ci", group = "Chores" },
+]
+
+# Protect breaking changes from being skipped
+protect_breaking_commits = true
+
+# Filter out commits by matching patterns
+filter_commits = false
+
+# Tag pattern for version detection
+tag_pattern = "v[0-9].*"
+
+# Sort commits by newest first
+sort_commits = "newest"
+
+[remote.github]
+owner = "zarzet"
+repo = "SpotiFLAC-Mobile"
diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go
index faf4ccf2..7a906f8e 100644
--- a/go_backend/audio_metadata.go
+++ b/go_backend/audio_metadata.go
@@ -1566,7 +1566,14 @@ func base64StdDecode(dst, src []byte) (int, error) {
}
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
+ return extractAnyCoverArtWithHint(filePath, "")
+}
+
+func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
+ if ext == "" {
+ ext = strings.ToLower(filepath.Ext(displayNameHint))
+ }
switch ext {
case ".flac":
@@ -1587,7 +1594,19 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
return extractOggCoverArt(filePath)
case ".m4a":
- return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
+ data, err := extractCoverFromM4A(filePath)
+ if err != nil {
+ return nil, "", err
+ }
+ mimeType := "image/jpeg"
+ if len(data) >= 8 &&
+ data[0] == 0x89 &&
+ data[1] == 0x50 &&
+ data[2] == 0x4E &&
+ data[3] == 0x47 {
+ mimeType = "image/png"
+ }
+ return data, mimeType, nil
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
@@ -1595,6 +1614,10 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
}
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
+ return SaveCoverToCacheWithHint(filePath, "", cacheDir)
+}
+
+func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
@@ -1611,7 +1634,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return pngPath, nil
}
- imageData, mimeType, err := extractAnyCoverArt(filePath)
+ imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
diff --git a/go_backend/cover.go b/go_backend/cover.go
index fd0ed822..10c89963 100644
--- a/go_backend/cover.go
+++ b/go_backend/cover.go
@@ -104,7 +104,6 @@ func upgradeDeezerCover(coverURL string) string {
return coverURL
}
- // Replace any size pattern with 1800x1800
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
if upgraded != coverURL {
GoLog("[Cover] Deezer: upgraded to 1800x1800")
diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go
new file mode 100644
index 00000000..8cbf1f61
--- /dev/null
+++ b/go_backend/cue_parser.go
@@ -0,0 +1,590 @@
+package gobackend
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// 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"`
+ FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
+ Genre string `json:"genre,omitempty"`
+ Date string `json:"date,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ Composer string `json:"composer,omitempty"`
+ Tracks []CueTrack `json:"tracks"`
+}
+
+// CueTrack represents a single track in a cue sheet
+type CueTrack struct {
+ Number int `json:"number"`
+ Title string `json:"title"`
+ 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)
+}
+
+// CueSplitInfo represents the information needed to split a CUE+audio file
+type CueSplitInfo struct {
+ CuePath string `json:"cue_path"`
+ AudioPath string `json:"audio_path"`
+ Album string `json:"album"`
+ Artist string `json:"artist"`
+ Genre string `json:"genre,omitempty"`
+ Date string `json:"date,omitempty"`
+ Tracks []CueSplitTrack `json:"tracks"`
+}
+
+// CueSplitTrack has the FFmpeg split parameters for a single track
+type CueSplitTrack struct {
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Artist string `json:"artist"`
+ ISRC string `json:"isrc,omitempty"`
+ Composer string `json:"composer,omitempty"`
+ StartSec float64 `json:"start_sec"`
+ EndSec float64 `json:"end_sec"` // -1 means until end of file
+}
+
+var (
+ reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
+ reQuoted = regexp.MustCompile(`"([^"]*)"`)
+)
+
+// ParseCueFile parses a .cue file and returns a CueSheet
+func ParseCueFile(cuePath string) (*CueSheet, error) {
+ f, err := os.Open(cuePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open cue file: %w", err)
+ }
+ defer f.Close()
+
+ sheet := &CueSheet{}
+ var currentTrack *CueTrack
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ 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)
+ }
+
+ upper := strings.ToUpper(line)
+
+ // REM commands (album-level metadata)
+ if strings.HasPrefix(upper, "REM ") {
+ matches := reRemCommand.FindStringSubmatch(line)
+ if len(matches) == 3 {
+ key := strings.ToUpper(matches[1])
+ value := unquoteCue(matches[2])
+ switch key {
+ case "GENRE":
+ sheet.Genre = value
+ case "DATE":
+ sheet.Date = value
+ case "COMMENT":
+ sheet.Comment = value
+ case "COMPOSER":
+ if currentTrack != nil {
+ currentTrack.Composer = value
+ } else {
+ sheet.Composer = value
+ }
+ }
+ }
+ continue
+ }
+
+ if strings.HasPrefix(upper, "PERFORMER ") {
+ value := unquoteCue(line[len("PERFORMER "):])
+ if currentTrack != nil {
+ currentTrack.Performer = value
+ } else {
+ sheet.Performer = value
+ }
+ continue
+ }
+
+ if strings.HasPrefix(upper, "TITLE ") {
+ value := unquoteCue(line[len("TITLE "):])
+ if currentTrack != nil {
+ currentTrack.Title = value
+ } else {
+ sheet.Title = value
+ }
+ continue
+ }
+
+ 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
+ continue
+ }
+
+ if strings.HasPrefix(upper, "TRACK ") {
+ // Save previous track
+ if currentTrack != nil {
+ sheet.Tracks = append(sheet.Tracks, *currentTrack)
+ }
+
+ parts := strings.Fields(line)
+ trackNum := 0
+ if len(parts) >= 2 {
+ trackNum, _ = strconv.Atoi(parts[1])
+ }
+
+ currentTrack = &CueTrack{
+ Number: trackNum,
+ PreGap: -1,
+ }
+ continue
+ }
+
+ if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
+ parts := strings.Fields(line)
+ if len(parts) >= 3 {
+ indexNum, _ := strconv.Atoi(parts[1])
+ timeSec := parseCueTimestamp(parts[2])
+ switch indexNum {
+ case 0:
+ currentTrack.PreGap = timeSec
+ case 1:
+ currentTrack.StartTime = timeSec
+ }
+ }
+ continue
+ }
+
+ if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
+ currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
+ continue
+ }
+
+ // SONGWRITER (used as composer sometimes)
+ if strings.HasPrefix(upper, "SONGWRITER ") {
+ value := unquoteCue(line[len("SONGWRITER "):])
+ if currentTrack != nil {
+ currentTrack.Composer = value
+ } else {
+ sheet.Composer = value
+ }
+ continue
+ }
+ }
+
+ // Don't forget the last track
+ if currentTrack != nil {
+ sheet.Tracks = append(sheet.Tracks, *currentTrack)
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("error reading cue file: %w", err)
+ }
+
+ if len(sheet.Tracks) == 0 {
+ return nil, fmt.Errorf("no tracks found in cue file")
+ }
+
+ return sheet, nil
+}
+
+// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
+func parseCueTimestamp(ts string) float64 {
+ parts := strings.Split(ts, ":")
+ if len(parts) != 3 {
+ return 0
+ }
+
+ minutes, _ := strconv.Atoi(parts[0])
+ seconds, _ := strconv.Atoi(parts[1])
+ frames, _ := strconv.Atoi(parts[2])
+
+ return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
+}
+
+// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
+func formatCueTimestamp(seconds float64) string {
+ if seconds < 0 {
+ return "0"
+ }
+ hours := int(seconds) / 3600
+ mins := (int(seconds) % 3600) / 60
+ secs := seconds - float64(hours*3600) - float64(mins*60)
+ return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
+}
+
+// unquoteCue removes surrounding quotes from a CUE value
+func unquoteCue(s string) string {
+ s = strings.TrimSpace(s)
+ if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
+ return matches[1]
+ }
+ return s
+}
+
+// parseCueFileLine parses the FILE command's filename and type
+func parseCueFileLine(rest string) (string, string) {
+ rest = strings.TrimSpace(rest)
+
+ var filename, ftype string
+
+ if strings.HasPrefix(rest, "\"") {
+ // Quoted filename
+ endQuote := strings.Index(rest[1:], "\"")
+ if endQuote >= 0 {
+ filename = rest[1 : endQuote+1]
+ remaining := strings.TrimSpace(rest[endQuote+2:])
+ ftype = remaining
+ } else {
+ filename = rest
+ }
+ } else {
+ // Unquoted filename - last word is the type
+ parts := strings.Fields(rest)
+ if len(parts) >= 2 {
+ ftype = parts[len(parts)-1]
+ filename = strings.Join(parts[:len(parts)-1], " ")
+ } else if len(parts) == 1 {
+ filename = parts[0]
+ }
+ }
+
+ return filename, strings.TrimSpace(ftype)
+}
+
+// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
+// It checks relative to the cue file's directory.
+func ResolveCueAudioPath(cuePath string, cueFileName string) string {
+ cueDir := filepath.Dir(cuePath)
+
+ // 1. Try the exact filename from the .cue
+ candidate := filepath.Join(cueDir, cueFileName)
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+
+ // 2. Try common case variations
+ baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
+ commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
+ for _, ext := range commonExts {
+ candidate = filepath.Join(cueDir, baseName+ext)
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+ // Try uppercase ext
+ candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+ }
+
+ // 3. Try to find any audio file with the same base name as the .cue file
+ cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
+ for _, ext := range commonExts {
+ candidate = filepath.Join(cueDir, cueBase+ext)
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+ }
+
+ // 4. If there's only one audio file in the directory, use that
+ entries, err := os.ReadDir(cueDir)
+ if err == nil {
+ audioExts := map[string]bool{
+ ".flac": true, ".wav": true, ".ape": true, ".mp3": true,
+ ".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
+ }
+ var audioFiles []string
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ ext := strings.ToLower(filepath.Ext(entry.Name()))
+ if audioExts[ext] {
+ audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
+ }
+ }
+ if len(audioFiles) == 1 {
+ return audioFiles[0]
+ }
+ }
+
+ return ""
+}
+
+// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
+// This is returned to the Dart side so FFmpeg can perform the splitting.
+// audioDir, if non-empty, overrides the directory for audio file resolution.
+func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
+ resolveDir := cuePath
+ if audioDir != "" {
+ // Create a virtual path in audioDir so ResolveCueAudioPath looks there
+ resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
+ }
+ audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
+ if audioPath == "" {
+ return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
+ }
+
+ info := &CueSplitInfo{
+ CuePath: cuePath,
+ AudioPath: audioPath,
+ Album: sheet.Title,
+ Artist: sheet.Performer,
+ Genre: sheet.Genre,
+ Date: sheet.Date,
+ }
+
+ for i, track := range sheet.Tracks {
+ performer := track.Performer
+ if performer == "" {
+ performer = sheet.Performer
+ }
+
+ composer := track.Composer
+ if composer == "" {
+ composer = sheet.Composer
+ }
+
+ // End time is the start of the next track, or -1 for the last track
+ endSec := float64(-1)
+ if i+1 < len(sheet.Tracks) {
+ nextTrack := sheet.Tracks[i+1]
+ // Use pre-gap of next track if available, otherwise its start time
+ if nextTrack.PreGap >= 0 {
+ endSec = nextTrack.PreGap
+ } else {
+ endSec = nextTrack.StartTime
+ }
+ }
+
+ info.Tracks = append(info.Tracks, CueSplitTrack{
+ Number: track.Number,
+ Title: track.Title,
+ Artist: performer,
+ ISRC: track.ISRC,
+ Composer: composer,
+ StartSec: track.StartTime,
+ EndSec: endSec,
+ })
+ }
+
+ return info, nil
+}
+
+// ParseCueFileJSON parses a .cue file and returns JSON with split info.
+// This is the main entry point called from Dart via the platform bridge.
+// audioDir, if non-empty, overrides the directory used for resolving the
+// referenced audio file (useful when the .cue was copied to a temp dir
+// but the audio still lives in the original location, e.g. SAF).
+func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
+ sheet, err := ParseCueFile(cuePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse cue file: %w", err)
+ }
+
+ info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
+ if err != nil {
+ return "", err
+ }
+
+ jsonBytes, err := json.Marshal(info)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal cue split info: %w", err)
+ }
+
+ return string(jsonBytes), nil
+}
+
+// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
+// entries, one per track. This is used by the library scanner to populate the
+// library with individual track entries from a single CUE+FLAC album.
+func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
+ sheet, err := ParseCueFile(cuePath)
+ if err != nil {
+ return nil, err
+ }
+ audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
+ if err != nil {
+ return nil, err
+ }
+ return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime)
+}
+
+// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
+// for SAF (Storage Access Framework) scenarios:
+// - audioDir: if non-empty, overrides the directory used to find the audio file
+// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
+// virtual file paths (e.g. a content:// URI). IDs are also based on this.
+// - fileModTime: if > 0, used as the FileModTime for all results instead of
+// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
+func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
+ sheet, err := ParseCueFile(cuePath)
+ if err != nil {
+ return nil, err
+ }
+ audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
+ if err != nil {
+ return nil, err
+ }
+ return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime)
+}
+
+func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
+ if sheet == nil {
+ return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
+ }
+ resolveBase := cuePath
+ if audioDir != "" {
+ resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
+ }
+ audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
+ if audioPath == "" {
+ return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
+ }
+ return audioPath, nil
+}
+
+func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
+ if sheet == nil {
+ return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
+ }
+
+ // Try to get quality info from the audio file
+ var bitDepth, sampleRate int
+ var totalDurationSec float64
+ audioExt := strings.ToLower(filepath.Ext(audioPath))
+ switch audioExt {
+ case ".flac":
+ quality, qErr := GetAudioQuality(audioPath)
+ if qErr == nil {
+ bitDepth = quality.BitDepth
+ sampleRate = quality.SampleRate
+ if quality.SampleRate > 0 && quality.TotalSamples > 0 {
+ totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
+ }
+ }
+ case ".mp3":
+ quality, qErr := GetMP3Quality(audioPath)
+ if qErr == nil {
+ sampleRate = quality.SampleRate
+ totalDurationSec = float64(quality.Duration)
+ }
+ }
+
+ // Extract cover from audio file for all tracks
+ var coverPath string
+ libraryCoverCacheMu.RLock()
+ coverCacheDir := libraryCoverCacheDir
+ libraryCoverCacheMu.RUnlock()
+ if coverCacheDir != "" {
+ cp, err := SaveCoverToCache(audioPath, coverCacheDir)
+ if err == nil && cp != "" {
+ coverPath = cp
+ }
+ }
+
+ // Determine the base path for virtual paths and IDs
+ pathBase := cuePath
+ if virtualPathPrefix != "" {
+ pathBase = virtualPathPrefix
+ }
+
+ // Determine fileModTime
+ modTime := fileModTime
+ if modTime <= 0 {
+ if info, err := os.Stat(cuePath); err == nil {
+ modTime = info.ModTime().UnixMilli()
+ }
+ }
+
+ var results []LibraryScanResult
+ for i, track := range sheet.Tracks {
+ performer := track.Performer
+ if performer == "" {
+ performer = sheet.Performer
+ }
+ if performer == "" {
+ performer = "Unknown Artist"
+ }
+
+ title := track.Title
+ if title == "" {
+ title = fmt.Sprintf("Track %02d", track.Number)
+ }
+
+ album := sheet.Title
+ if album == "" {
+ album = "Unknown Album"
+ }
+
+ // Calculate duration for this track
+ var duration int
+ if i+1 < len(sheet.Tracks) {
+ nextStart := sheet.Tracks[i+1].StartTime
+ if sheet.Tracks[i+1].PreGap >= 0 {
+ nextStart = sheet.Tracks[i+1].PreGap
+ }
+ duration = int(nextStart - track.StartTime)
+ } else if totalDurationSec > 0 {
+ duration = int(totalDurationSec - track.StartTime)
+ }
+
+ id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
+
+ // Use a virtual file path that includes the track number to ensure
+ // uniqueness in the database (file_path has a UNIQUE constraint).
+ // Format: /path/to/album.cue#track01 or content://...album.cue#track01
+ virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
+
+ result := LibraryScanResult{
+ ID: id,
+ TrackName: title,
+ ArtistName: performer,
+ AlbumName: album,
+ AlbumArtist: sheet.Performer,
+ FilePath: virtualFilePath,
+ CoverPath: coverPath,
+ ScannedAt: scanTime,
+ ISRC: track.ISRC,
+ TrackNumber: track.Number,
+ DiscNumber: 1,
+ Duration: duration,
+ ReleaseDate: sheet.Date,
+ BitDepth: bitDepth,
+ SampleRate: sampleRate,
+ Genre: sheet.Genre,
+ Format: "cue+" + strings.TrimPrefix(audioExt, "."),
+ }
+
+ result.FileModTime = modTime
+
+ results = append(results, result)
+ }
+
+ return results, nil
+}
diff --git a/go_backend/deezer.go b/go_backend/deezer.go
index 568e61c4..a389f3c8 100644
--- a/go_backend/deezer.go
+++ b/go_backend/deezer.go
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
+ Copyright string `json:"copyright"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
}
type AlbumExtendedMetadata struct {
- Genre string
- Label string
+ Genre string
+ Label string
+ Copyright string
}
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
@@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
}
result := &AlbumExtendedMetadata{
- Genre: strings.Join(genres, ", "),
- Label: album.Label,
+ Genre: strings.Join(genres, ", "),
+ Label: album.Label,
+ Copyright: album.Copyright,
}
c.cacheMu.Lock()
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
- GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
+ GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
return result, nil
}
diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go
index 01e5c654..974ffcf6 100644
--- a/go_backend/deezer_download.go
+++ b/go_backend/deezer_download.go
@@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
}
}
if deezerID != "" {
- return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
+ trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
+ if err := verifyDeezerTrack(req, deezerID); err != nil {
+ GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
+ // Don't reject direct IDs from request payload — they're presumably correct.
+ }
+ return trackURL, nil
}
- // Try resolving Deezer ID from Spotify ID via SongLink
+ // Try SongLink
spotifyID := strings.TrimSpace(req.SpotifyID)
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
songlink := NewSongLinkClient()
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
if err == nil && availability.Deezer && availability.DeezerURL != "" {
- return availability.DeezerURL, nil
+ resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
+ if resolvedID != "" {
+ if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
+ GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
+ // Fall through to ISRC search instead of using wrong track.
+ } else {
+ return availability.DeezerURL, nil
+ }
+ } else {
+ return availability.DeezerURL, nil
+ }
}
}
- // Try resolving from ISRC
+ // Try ISRC
isrc := strings.TrimSpace(req.ISRC)
if isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
if err == nil && track != nil {
- deezerID = songLinkExtractDeezerTrackID(track)
- if deezerID != "" {
- return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
+ resolvedID := songLinkExtractDeezerTrackID(track)
+ if resolvedID != "" {
+ if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
+ GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
+ return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
+ }
+ return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
}
}
}
@@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
return "", fmt.Errorf("could not resolve Deezer track URL")
}
+func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
+ defer cancel()
+ trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
+ if err != nil {
+ return nil // Can't verify — don't block the download.
+ }
+ resolved := resolvedTrackInfo{
+ Title: trackResp.Track.Name,
+ ArtistName: trackResp.Track.Artists,
+ Duration: trackResp.Track.DurationMS / 1000,
+ }
+ if !trackMatchesRequest(req, resolved, "Deezer") {
+ return fmt.Errorf("expected '%s - %s', got '%s - %s'",
+ req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
+ }
+ GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
+ return nil
+}
+
type deezerMusicDLRequest struct {
Platform string `json:"platform"`
URL string `json:"url"`
@@ -394,11 +433,6 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
}
}
- spotifyURL, err := resolveSpotifyURLForYoinkify(req)
- if err != nil {
- return DeezerDownloadResult{}, err
- }
-
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -461,6 +495,17 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
}
if downloadErr != nil || deezerURLErr != nil {
+ spotifyURL, err := resolveSpotifyURLForYoinkify(req)
+ if err != nil {
+ if deezerURLErr != nil {
+ return DeezerDownloadResult{}, fmt.Errorf(
+ "deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
+ deezerURLErr,
+ err,
+ )
+ }
+ return DeezerDownloadResult{}, err
+ }
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
if downloadErr != nil {
if errors.Is(downloadErr, ErrDownloadCancelled) {
diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go
index cd922285..30b68551 100644
--- a/go_backend/duplicate.go
+++ b/go_backend/duplicate.go
@@ -34,7 +34,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return idx
}
- // Slow path: need to build index
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
diff --git a/go_backend/exports.go b/go_backend/exports.go
index 08efb1fb..39184350 100644
--- a/go_backend/exports.go
+++ b/go_backend/exports.go
@@ -32,126 +32,6 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil
}
-func SetSpotifyAPICredentials(clientID, clientSecret string) {
- SetSpotifyCredentials(clientID, clientSecret)
-}
-
-func CheckSpotifyCredentials() bool {
- return HasSpotifyCredentials()
-}
-
-func GetSpotifyMetadata(spotifyURL string) (string, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- client, err := NewSpotifyMetadataClient()
- if err != nil {
- if shouldTrySpotFetchFallback(err) {
- data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
- if apiErr == nil {
- jsonBytes, marshalErr := json.Marshal(data)
- if marshalErr != nil {
- return "", marshalErr
- }
- return string(jsonBytes), nil
- }
- }
- return "", err
- }
- data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
- if err != nil {
- if shouldTrySpotFetchFallback(err) {
- fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
- if apiErr == nil {
- jsonBytes, marshalErr := json.Marshal(fallbackData)
- if marshalErr != nil {
- return "", marshalErr
- }
- return string(jsonBytes), nil
- }
- }
- return "", err
- }
-
- jsonBytes, err := json.Marshal(data)
- if err != nil {
- return "", err
- }
-
- return string(jsonBytes), nil
-}
-
-func SearchSpotify(query string, limit int) (string, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
-
- client, err := NewSpotifyMetadataClient()
- if err != nil {
- return "", err
- }
- results, err := client.SearchTracks(ctx, query, limit)
- if err != nil {
- return "", err
- }
-
- jsonBytes, err := json.Marshal(results)
- if err != nil {
- return "", err
- }
-
- return string(jsonBytes), nil
-}
-
-func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
-
- client, err := NewSpotifyMetadataClient()
- if err != nil {
- return "", err
- }
- results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
- if err != nil {
- return "", err
- }
-
- jsonBytes, err := json.Marshal(results)
- if err != nil {
- return "", err
- }
-
- return string(jsonBytes), nil
-}
-
-func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) {
- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- defer cancel()
-
- client, err := NewSpotifyMetadataClient()
- if err != nil {
- return "", err
- }
-
- normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:"))
- if normalizedArtistID == "" {
- return "", fmt.Errorf("invalid Spotify artist ID")
- }
-
- artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit)
- if err != nil {
- return "", err
- }
-
- resp := map[string]interface{}{
- "artists": artists,
- }
- jsonBytes, err := json.Marshal(resp)
- if err != nil {
- return "", err
- }
- return string(jsonBytes), nil
-}
-
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -248,6 +128,7 @@ type DownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
+ CoverURL string
Genre string
Label string
Copyright string
@@ -255,6 +136,36 @@ type DownloadResult struct {
DecryptionKey string
}
+func preferredReleaseMetadata(
+ req DownloadRequest,
+ album string,
+ releaseDate string,
+ trackNumber int,
+ discNumber int,
+) (string, string, int, int) {
+ preferredAlbum := strings.TrimSpace(req.AlbumName)
+ if preferredAlbum == "" {
+ preferredAlbum = album
+ }
+
+ preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
+ if preferredReleaseDate == "" {
+ preferredReleaseDate = releaseDate
+ }
+
+ preferredTrackNumber := req.TrackNumber
+ if preferredTrackNumber == 0 {
+ preferredTrackNumber = trackNumber
+ }
+
+ preferredDiscNumber := req.DiscNumber
+ if preferredDiscNumber == 0 {
+ preferredDiscNumber = discNumber
+ }
+
+ return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
+}
+
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
@@ -273,25 +184,16 @@ func buildDownloadSuccessResponse(
artist = req.ArtistName
}
- album := result.Album
- if album == "" {
- album = req.AlbumName
- }
-
- releaseDate := result.ReleaseDate
- if releaseDate == "" {
- releaseDate = req.ReleaseDate
- }
-
- trackNumber := result.TrackNumber
- if trackNumber == 0 {
- trackNumber = req.TrackNumber
- }
-
- discNumber := result.DiscNumber
- if discNumber == 0 {
- discNumber = req.DiscNumber
- }
+ // Preserve requested release metadata when available so mixed-provider
+ // fallback downloads from the same source album do not get split into
+ // different albums just because Tidal/Qobuz report variant titles/dates.
+ album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
+ req,
+ result.Album,
+ result.ReleaseDate,
+ result.TrackNumber,
+ result.DiscNumber,
+ )
isrc := result.ISRC
if isrc == "" {
@@ -313,6 +215,11 @@ func buildDownloadSuccessResponse(
copyright = req.Copyright
}
+ coverURL := strings.TrimSpace(result.CoverURL)
+ if coverURL == "" {
+ coverURL = strings.TrimSpace(req.CoverURL)
+ }
+
return DownloadResponse{
Success: true,
Message: message,
@@ -329,7 +236,7 @@ func buildDownloadSuccessResponse(
TrackNumber: trackNumber,
DiscNumber: discNumber,
ISRC: isrc,
- CoverURL: req.CoverURL,
+ CoverURL: coverURL,
Genre: genre,
Label: label,
Copyright: copyright,
@@ -382,7 +289,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return
}
- if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
+ if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
return
}
@@ -404,8 +311,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
- if req.Genre != "" || req.Label != "" {
- GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
+ if req.Copyright == "" && extMeta.Copyright != "" {
+ req.Copyright = extMeta.Copyright
+ }
+ if req.Genre != "" || req.Label != "" || req.Copyright != "" {
+ GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -474,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
+ CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC,
}
}
@@ -682,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
+ CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC,
}
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
@@ -835,6 +747,26 @@ func ReadFileMetadata(filePath string) (string, error) {
}
}
} else if isM4A {
+ meta, err := ReadM4ATags(filePath)
+ if err == nil && meta != nil {
+ result["title"] = meta.Title
+ result["artist"] = meta.Artist
+ result["album"] = meta.Album
+ result["album_artist"] = meta.AlbumArtist
+ result["date"] = meta.Date
+ if meta.Date == "" {
+ result["date"] = meta.Year
+ }
+ result["track_number"] = meta.TrackNumber
+ result["disc_number"] = meta.DiscNumber
+ result["isrc"] = meta.ISRC
+ result["lyrics"] = meta.Lyrics
+ result["genre"] = meta.Genre
+ result["label"] = meta.Label
+ result["copyright"] = meta.Copyright
+ result["composer"] = meta.Composer
+ result["comment"] = meta.Comment
+ }
quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
@@ -901,6 +833,32 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil
}
+// ParseCueSheet parses a .cue file and returns JSON with split information.
+// This is called from Dart to get track listing and timing data for CUE splitting.
+// audioDir, if non-empty, overrides the directory used for resolving the
+// referenced audio file (useful for SAF temp file scenarios).
+func ParseCueSheet(cuePath string, audioDir string) (string, error) {
+ return ParseCueFileJSON(cuePath, audioDir)
+}
+
+// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
+// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
+// - audioDir overrides where the referenced audio file is resolved
+// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
+// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
+func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
+ scanTime := time.Now().UTC().Format(time.RFC3339)
+ results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
+ if err != nil {
+ return "[]", err
+ }
+ jsonBytes, err := json.Marshal(results)
+ if err != nil {
+ return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
+ }
+ return string(jsonBytes), nil
+}
+
// EditFileMetadata writes metadata to an audio file.
// For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
@@ -1197,6 +1155,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
return string(jsonBytes), nil
}
+func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
+ downloader := NewTidalDownloader()
+ results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
+ if err != nil {
+ return "", err
+ }
+
+ jsonBytes, err := json.Marshal(results)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonBytes), nil
+}
+
+func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
+ downloader := NewQobuzDownloader()
+ results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
+ if err != nil {
+ return "", err
+ }
+
+ jsonBytes, err := json.Marshal(results)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonBytes), nil
+}
+
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
@@ -1250,6 +1238,66 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
return string(jsonBytes), nil
}
+func GetQobuzMetadata(resourceType, resourceID string) (string, error) {
+ downloader := NewQobuzDownloader()
+
+ var data interface{}
+ var err error
+
+ switch resourceType {
+ case "track":
+ data, err = downloader.GetTrackMetadata(resourceID)
+ case "album":
+ data, err = downloader.GetAlbumMetadata(resourceID)
+ case "artist":
+ data, err = downloader.GetArtistMetadata(resourceID)
+ case "playlist":
+ data, err = downloader.GetPlaylistMetadata(resourceID)
+ default:
+ return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType)
+ }
+ if err != nil {
+ return "", err
+ }
+
+ jsonBytes, err := json.Marshal(data)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonBytes), nil
+}
+
+func GetTidalMetadata(resourceType, resourceID string) (string, error) {
+ downloader := NewTidalDownloader()
+
+ var data interface{}
+ var err error
+
+ switch resourceType {
+ case "track":
+ data, err = downloader.GetTrackMetadata(resourceID)
+ case "album":
+ data, err = downloader.GetAlbumMetadata(resourceID)
+ case "artist":
+ data, err = downloader.GetArtistMetadata(resourceID)
+ case "playlist":
+ data, err = downloader.GetPlaylistMetadata(resourceID)
+ default:
+ return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
+ }
+ if err != nil {
+ return "", err
+ }
+
+ jsonBytes, err := json.Marshal(data)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonBytes), nil
+}
+
func ParseDeezerURLExport(url string) (string, error) {
resourceType, resourceID, err := parseDeezerURL(url)
if err != nil {
@@ -1269,6 +1317,25 @@ func ParseDeezerURLExport(url string) (string, error) {
return string(jsonBytes), nil
}
+func ParseQobuzURLExport(url string) (string, error) {
+ resourceType, resourceID, err := parseQobuzURL(url)
+ if err != nil {
+ return "", err
+ }
+
+ result := map[string]string{
+ "type": resourceType,
+ "id": resourceID,
+ }
+
+ jsonBytes, err := json.Marshal(result)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonBytes), nil
+}
+
func ParseTidalURLExport(url string) (string, error) {
resourceType, resourceID, err := parseTidalURL(url)
if err != nil {
@@ -1329,10 +1396,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
return "", err
}
- result := map[string]string{
- "genre": metadata.Genre,
- "label": metadata.Label,
- }
+ result := buildDeezerExtendedMetadataResult(metadata)
jsonBytes, err := json.Marshal(result)
if err != nil {
@@ -1352,7 +1416,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return "", err
}
- jsonBytes, err := json.Marshal(track)
+ result := buildDeezerISRCSearchResult(track)
+ jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
@@ -1360,6 +1425,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return string(jsonBytes), nil
}
+func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
+ if metadata == nil {
+ return map[string]string{
+ "genre": "",
+ "label": "",
+ "copyright": "",
+ }
+ }
+
+ return map[string]string{
+ "genre": metadata.Genre,
+ "label": metadata.Label,
+ "copyright": metadata.Copyright,
+ }
+}
+
+func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
+ if track == nil {
+ return map[string]interface{}{}
+ }
+
+ result := map[string]interface{}{
+ "spotify_id": track.SpotifyID,
+ "artists": track.Artists,
+ "name": track.Name,
+ "album_name": track.AlbumName,
+ "album_artist": track.AlbumArtist,
+ "duration_ms": track.DurationMS,
+ "images": track.Images,
+ "release_date": track.ReleaseDate,
+ "track_number": track.TrackNumber,
+ "total_tracks": track.TotalTracks,
+ "disc_number": track.DiscNumber,
+ "external_urls": track.ExternalURL,
+ "isrc": track.ISRC,
+ "album_id": track.AlbumID,
+ "artist_id": track.ArtistID,
+ "album_type": track.AlbumType,
+ }
+
+ if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
+ result["id"] = deezerID
+ result["track_id"] = deezerID
+ result["success"] = true
+ }
+
+ return result
+}
+
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -1405,7 +1519,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return string(jsonBytes), nil
}
- // For artists/playlists, SongLink doesn't provide direct mapping
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
}
@@ -1413,28 +1526,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- var spotifyErr error
-
- client, err := NewSpotifyMetadataClient()
- if err != nil {
- LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
- spotifyErr = err
- } else {
- data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
- if err == nil {
- jsonBytes, err := json.Marshal(data)
- if err != nil {
- return "", err
- }
- return string(jsonBytes), nil
- }
-
- spotifyErr = err
- if !shouldTrySpotFetchFallback(err) {
- return "", err
- }
- }
-
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
@@ -1448,9 +1539,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
- if spotifyErr != nil {
- return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
- }
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
}
@@ -1461,15 +1549,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
}
if parsed.Type == "artist" {
- if spotifyErr != nil {
- return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
- }
- return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
+ return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
}
- if spotifyErr != nil {
- return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
- }
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
}
@@ -1670,6 +1752,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
+ } else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
+ coverData, err = extractCoverFromM4A(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
@@ -1800,114 +1884,58 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
- // When search_online is true, search for metadata from internet
- // Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
+ // When search_online is true, search for metadata from internet using the
+ // configured metadata-provider priority.
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
found := false
- // 1) Try Deezer first (reliable, no credentials needed)
- GoLog("[ReEnrich] Trying Deezer search...\n")
deezerClient := GetDeezerClient()
- {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track")
- cancel()
- if err == nil && len(deezerResults.Tracks) > 0 {
- track := deezerResults.Tracks[0]
- GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
- req.SpotifyID = "deezer:" + track.SpotifyID
- req.AlbumName = track.AlbumName
- req.AlbumArtist = track.AlbumArtist
- req.TrackNumber = track.TrackNumber
- req.DiscNumber = track.DiscNumber
- req.ReleaseDate = track.ReleaseDate
- req.ISRC = track.ISRC
- if track.Images != "" {
- req.CoverURL = track.Images
- }
- req.DurationMs = int64(track.DurationMS)
- found = true
- } else if err != nil {
- GoLog("[ReEnrich] Deezer search failed: %v\n", err)
- }
- }
-
- // 2) Try extension metadata providers (spotify-web etc) if Deezer failed
- if !found {
- GoLog("[ReEnrich] Trying extension metadata providers...\n")
- manager := GetExtensionManager()
- extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5)
- if extErr == nil && len(extTracks) > 0 {
- track := extTracks[0]
- GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
- if track.SpotifyID != "" {
- req.SpotifyID = track.SpotifyID
- } else if track.DeezerID != "" {
- req.SpotifyID = "deezer:" + track.DeezerID
- } else {
- req.SpotifyID = track.ID
- }
- req.AlbumName = track.AlbumName
- req.AlbumArtist = track.AlbumArtist
- req.TrackNumber = track.TrackNumber
- req.DiscNumber = track.DiscNumber
- req.ReleaseDate = track.ReleaseDate
- req.ISRC = track.ISRC
- coverURL := track.ResolvedCoverURL()
- if coverURL != "" {
- req.CoverURL = coverURL
- }
- req.DurationMs = int64(track.DurationMS)
- if track.Genre != "" {
- req.Genre = track.Genre
- }
- if track.Label != "" {
- req.Label = track.Label
- }
- if track.Copyright != "" {
- req.Copyright = track.Copyright
- }
- found = true
- } else if extErr != nil {
- GoLog("[ReEnrich] Extension search failed: %v\n", extErr)
- }
- }
-
- // 3) Try Spotify built-in API as last resort (will be deprecated)
- if !found {
- GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
- spotifyClient, spotifyErr := NewSpotifyMetadataClient()
- if spotifyErr == nil {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
- cancel()
- if err == nil && len(results.Tracks) > 0 {
- track := results.Tracks[0]
- GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
- req.SpotifyID = track.SpotifyID
- req.AlbumName = track.AlbumName
- req.AlbumArtist = track.AlbumArtist
- req.TrackNumber = track.TrackNumber
- req.DiscNumber = track.DiscNumber
- req.ReleaseDate = track.ReleaseDate
- req.ISRC = track.ISRC
- if track.Images != "" {
- req.CoverURL = track.Images
- }
- req.DurationMs = int64(track.DurationMS)
- found = true
- } else if err != nil {
- GoLog("[ReEnrich] Spotify search failed: %v\n", err)
- }
+ GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
+ manager := GetExtensionManager()
+ tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
+ if searchErr == nil && len(tracks) > 0 {
+ track := tracks[0]
+ GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
+ if track.SpotifyID != "" {
+ req.SpotifyID = track.SpotifyID
+ } else if track.DeezerID != "" {
+ req.SpotifyID = "deezer:" + track.DeezerID
+ } else if track.QobuzID != "" {
+ req.SpotifyID = "qobuz:" + track.QobuzID
+ } else if track.TidalID != "" {
+ req.SpotifyID = "tidal:" + track.TidalID
} else {
- GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
+ req.SpotifyID = track.ID
}
+ req.AlbumName = track.AlbumName
+ req.AlbumArtist = track.AlbumArtist
+ req.TrackNumber = track.TrackNumber
+ req.DiscNumber = track.DiscNumber
+ req.ReleaseDate = track.ReleaseDate
+ req.ISRC = track.ISRC
+ coverURL := track.ResolvedCoverURL()
+ if coverURL != "" {
+ req.CoverURL = coverURL
+ }
+ req.DurationMs = int64(track.DurationMS)
+ if track.Genre != "" {
+ req.Genre = track.Genre
+ }
+ if track.Label != "" {
+ req.Label = track.Label
+ }
+ if track.Copyright != "" {
+ req.Copyright = track.Copyright
+ }
+ found = true
+ } else if searchErr != nil {
+ GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
- // Try to get extended metadata (genre, label) from Deezer if not already set
- if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
+ // Try to get extended metadata from Deezer if not already set
+ if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
@@ -1918,7 +1946,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
- GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
+ if req.Copyright == "" && extMeta.Copyright != "" {
+ req.Copyright = extMeta.Copyright
+ }
+ GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -1987,8 +2018,15 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}()
- // Fetch lyrics
+ // Preserve existing lyrics when online enrichment does not return a replacement.
var lyricsLRC string
+ existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
+ if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
+ lyricsLRC = existingLyrics
+ GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
+ }
+
+ // Fetch lyrics
if req.EmbedLyrics {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
@@ -2069,7 +2107,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
return string(jsonBytes), nil
}
- // MP3/Opus: return metadata map for Dart to use FFmpeg
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
result := map[string]interface{}{
@@ -2305,6 +2342,21 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
return string(jsonBytes), nil
}
+func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
+ manager := GetExtensionManager()
+ tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
+ if err != nil {
+ return "", err
+ }
+
+ jsonBytes, err := json.Marshal(tracks)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonBytes), nil
+}
+
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
@@ -2690,6 +2742,28 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
artistResponse["albums"] = albums
}
+ if len(result.Artist.Releases) > 0 {
+ releases := make([]map[string]interface{}, len(result.Artist.Releases))
+ for i, release := range result.Artist.Releases {
+ releaseType := release.AlbumType
+ if releaseType == "" {
+ releaseType = "album"
+ }
+ releases[i] = map[string]interface{}{
+ "id": release.ID,
+ "name": release.Name,
+ "artists": release.Artists,
+ "images": release.CoverURL,
+ "cover_url": release.CoverURL,
+ "release_date": release.ReleaseDate,
+ "total_tracks": release.TotalTracks,
+ "album_type": releaseType,
+ "provider_id": release.ProviderID,
+ }
+ }
+ artistResponse["releases"] = releases
+ }
+
if len(result.Artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
for i, track := range result.Artist.TopTracks {
@@ -2939,6 +3013,27 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
"provider_id": artist.ProviderID,
}
+ if len(artist.Releases) > 0 {
+ releases := make([]map[string]interface{}, len(artist.Releases))
+ for i, release := range artist.Releases {
+ releaseType := release.AlbumType
+ if releaseType == "" {
+ releaseType = "album"
+ }
+ releases[i] = map[string]interface{}{
+ "id": release.ID,
+ "name": release.Name,
+ "artists": release.Artists,
+ "cover_url": release.CoverURL,
+ "release_date": release.ReleaseDate,
+ "total_tracks": release.TotalTracks,
+ "album_type": releaseType,
+ "provider_id": release.ProviderID,
+ }
+ }
+ response["releases"] = releases
+ }
+
if artist.HeaderImage != "" {
response["header_image"] = artist.HeaderImage
}
@@ -3086,6 +3181,45 @@ func InitExtensionStoreJSON(cacheDir string) error {
return nil
}
+func SetStoreRegistryURLJSON(registryURL string) error {
+ store := GetExtensionStore()
+ if store == nil {
+ return fmt.Errorf("extension store not initialized")
+ }
+
+ resolved, err := ResolveRegistryURL(registryURL)
+ if err != nil {
+ return err
+ }
+
+ if err := requireHTTPSURL(resolved, "registry"); err != nil {
+ return err
+ }
+
+ store.SetRegistryURL(resolved)
+ return nil
+}
+
+func ClearStoreRegistryURLJSON() error {
+ store := GetExtensionStore()
+ if store == nil {
+ return fmt.Errorf("extension store not initialized")
+ }
+
+ store.SetRegistryURL("")
+ store.ClearCache()
+ return nil
+}
+
+func GetStoreRegistryURLJSON() (string, error) {
+ store := GetExtensionStore()
+ if store == nil {
+ return "", fmt.Errorf("extension store not initialized")
+ }
+
+ return store.GetRegistryURL(), nil
+}
+
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
store := GetExtensionStore()
if store == nil {
@@ -3243,6 +3377,10 @@ func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (str
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}
+func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
+ return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
+}
+
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
@@ -3254,3 +3392,7 @@ func CancelLibraryScanJSON() {
func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
+
+func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
+ return ReadAudioMetadataWithDisplayName(filePath, displayName)
+}
diff --git a/go_backend/exports_deezer_metadata_test.go b/go_backend/exports_deezer_metadata_test.go
new file mode 100644
index 00000000..763965fc
--- /dev/null
+++ b/go_backend/exports_deezer_metadata_test.go
@@ -0,0 +1,59 @@
+package gobackend
+
+import "testing"
+
+func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
+ result := buildDeezerExtendedMetadataResult(nil)
+
+ if result["genre"] != "" {
+ t.Fatalf("expected empty genre, got %q", result["genre"])
+ }
+ if result["label"] != "" {
+ t.Fatalf("expected empty label, got %q", result["label"])
+ }
+ if result["copyright"] != "" {
+ t.Fatalf("expected empty copyright, got %q", result["copyright"])
+ }
+}
+
+func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
+ result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
+ Genre: "Rock",
+ Label: "EMI",
+ Copyright: "(C) Queen",
+ })
+
+ if result["genre"] != "Rock" {
+ t.Fatalf("unexpected genre: %q", result["genre"])
+ }
+ if result["label"] != "EMI" {
+ t.Fatalf("unexpected label: %q", result["label"])
+ }
+ if result["copyright"] != "(C) Queen" {
+ t.Fatalf("unexpected copyright: %q", result["copyright"])
+ }
+}
+
+func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
+ result := buildDeezerISRCSearchResult(&TrackMetadata{
+ SpotifyID: "deezer:3135556",
+ Name: "Love Of My Life",
+ Artists: "Queen",
+ AlbumName: "A Night at the Opera",
+ ISRC: "GBUM71029604",
+ ReleaseDate: "1975-11-21",
+ })
+
+ if result["spotify_id"] != "deezer:3135556" {
+ t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
+ }
+ if result["id"] != "3135556" {
+ t.Fatalf("unexpected id: %v", result["id"])
+ }
+ if result["track_id"] != "3135556" {
+ t.Fatalf("unexpected track_id: %v", result["track_id"])
+ }
+ if result["success"] != true {
+ t.Fatalf("expected success=true, got %v", result["success"])
+ }
+}
diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go
new file mode 100644
index 00000000..44faf294
--- /dev/null
+++ b/go_backend/exports_test.go
@@ -0,0 +1,115 @@
+package gobackend
+
+import "testing"
+
+func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
+ req := DownloadRequest{
+ TrackName: "Bonus Track",
+ ArtistName: "Artist",
+ AlbumName: "Album (Deluxe)",
+ AlbumArtist: "Artist",
+ ReleaseDate: "2024-01-01",
+ TrackNumber: 14,
+ DiscNumber: 1,
+ ISRC: "REQ123",
+ CoverURL: "https://example.com/cover.jpg",
+ Genre: "Pop",
+ Label: "Label",
+ Copyright: "Copyright",
+ }
+
+ result := DownloadResult{
+ Title: "Bonus Track",
+ Artist: "Artist",
+ Album: "Album",
+ ReleaseDate: "2023-12-01",
+ TrackNumber: 2,
+ DiscNumber: 9,
+ ISRC: "RES456",
+ }
+
+ resp := buildDownloadSuccessResponse(
+ req,
+ result,
+ "tidal",
+ "ok",
+ "/tmp/test.flac",
+ false,
+ )
+
+ if resp.Album != req.AlbumName {
+ t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
+ }
+ if resp.ReleaseDate != req.ReleaseDate {
+ t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
+ }
+ if resp.TrackNumber != req.TrackNumber {
+ t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
+ }
+ if resp.DiscNumber != req.DiscNumber {
+ t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
+ }
+ if resp.Artist != result.Artist {
+ t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
+ }
+ if resp.ISRC != result.ISRC {
+ t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
+ }
+}
+
+func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
+ album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
+ DownloadRequest{
+ AlbumName: "Album (Deluxe Edition)",
+ ReleaseDate: "2024-01-01",
+ TrackNumber: 13,
+ DiscNumber: 2,
+ },
+ "Album",
+ "2023-01-01",
+ 3,
+ 1,
+ )
+
+ if album != "Album (Deluxe Edition)" {
+ t.Fatalf("album = %q", album)
+ }
+ if releaseDate != "2024-01-01" {
+ t.Fatalf("release date = %q", releaseDate)
+ }
+ if trackNumber != 13 {
+ t.Fatalf("track number = %d", trackNumber)
+ }
+ if discNumber != 2 {
+ t.Fatalf("disc number = %d", discNumber)
+ }
+}
+
+func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
+ req := DownloadRequest{
+ TrackName: "Track",
+ ArtistName: "Artist",
+ AlbumName: "Album",
+ AlbumArtist: "Artist",
+ }
+
+ result := DownloadResult{
+ Title: "Track",
+ Artist: "Artist",
+ Album: "Album",
+ CoverURL: "https://cdn.qobuz.test/cover.jpg",
+ }
+
+ resp := buildDownloadSuccessResponse(
+ req,
+ result,
+ "qobuz",
+ "ok",
+ "/tmp/test.flac",
+ false,
+ )
+
+ if resp.CoverURL != result.CoverURL {
+ t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
+ }
+}
diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go
index 5c73e251..d2a1f8d3 100644
--- a/go_backend/extension_manager.go
+++ b/go_backend/extension_manager.go
@@ -151,7 +151,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
if exists {
versionCompare := compareVersions(manifest.Version, existingVersion)
if versionCompare > 0 {
- // This is an upgrade - call UpgradeExtension
return m.UpgradeExtension(filePath)
} else if versionCompare == 0 {
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
@@ -429,7 +428,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
SourceDir: dirPath,
}
- // Restore enabled state from settings store
store := GetExtensionSettingsStore()
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
if enabled, ok := enabledVal.(bool); ok {
diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go
index a1a419b5..a3e46450 100644
--- a/go_backend/extension_providers.go
+++ b/go_backend/extension_providers.go
@@ -70,6 +70,7 @@ type ExtArtistMetadata struct {
HeaderImage string `json:"header_image,omitempty"`
Listeners int `json:"listeners,omitempty"`
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
+ Releases []ExtAlbumMetadata `json:"releases,omitempty"`
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
ProviderID string `json:"provider_id"`
}
@@ -327,6 +328,12 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
}
artist.ProviderID = p.extension.ID
+ for i := range artist.Releases {
+ artist.Releases[i].ProviderID = p.extension.ID
+ for j := range artist.Releases[i].Tracks {
+ artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
+ }
+ }
return &artist, nil
}
@@ -484,7 +491,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return &urlResult, nil
}
-const ExtDownloadTimeout = 5 * time.Minute
+const ExtDownloadTimeout = DownloadTimeout
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
@@ -600,8 +607,30 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
return nil, nil
}
- var allTracks []ExtTrackMetadata
+ providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
+ orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
for _, provider := range providers {
+ providerByID[provider.extension.ID] = provider
+ }
+ for _, providerID := range GetMetadataProviderPriority() {
+ if provider := providerByID[providerID]; provider != nil {
+ orderedProviders = append(orderedProviders, provider)
+ delete(providerByID, providerID)
+ }
+ }
+ if len(providerByID) > 0 {
+ remainingIDs := make([]string, 0, len(providerByID))
+ for providerID := range providerByID {
+ remainingIDs = append(remainingIDs, providerID)
+ }
+ sort.Strings(remainingIDs)
+ for _, providerID := range remainingIDs {
+ orderedProviders = append(orderedProviders, providerByID[providerID])
+ }
+ }
+
+ var allTracks []ExtTrackMetadata
+ for _, provider := range orderedProviders {
result, err := provider.SearchTracks(query, limit)
if err != nil {
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
@@ -621,6 +650,8 @@ var providerPriorityMu sync.RWMutex
var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex
+var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
+
func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock()
defer providerPriorityMu.Unlock()
@@ -644,8 +675,30 @@ func GetProviderPriority() []string {
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
- metadataProviderPriority = providerIDs
- GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
+
+ sanitized := make([]string, 0, len(providerIDs)+3)
+ seen := map[string]struct{}{}
+ for _, providerID := range providerIDs {
+ providerID = strings.TrimSpace(providerID)
+ if providerID == "" || providerID == "spotify" {
+ continue
+ }
+ if _, exists := seen[providerID]; exists {
+ continue
+ }
+ seen[providerID] = struct{}{}
+ sanitized = append(sanitized, providerID)
+ }
+ for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
+ if _, exists := seen[providerID]; exists {
+ continue
+ }
+ seen[providerID] = struct{}{}
+ sanitized = append(sanitized, providerID)
+ }
+
+ metadataProviderPriority = sanitized
+ GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
}
func GetMetadataProviderPriority() []string {
@@ -653,7 +706,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
- return []string{"deezer", "spotify"}
+ return []string{"deezer", "qobuz", "tidal"}
}
result := make([]string, len(metadataProviderPriority))
@@ -670,6 +723,165 @@ func isBuiltInProvider(providerID string) bool {
}
}
+func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
+ deezerID := ""
+ tidalID := ""
+ qobuzID := ""
+ prefixedID := strings.TrimSpace(track.SpotifyID)
+
+ switch providerID {
+ case "deezer":
+ deezerID = strings.TrimPrefix(prefixedID, "deezer:")
+ case "tidal":
+ tidalID = strings.TrimPrefix(prefixedID, "tidal:")
+ case "qobuz":
+ qobuzID = strings.TrimPrefix(prefixedID, "qobuz:")
+ }
+
+ return ExtTrackMetadata{
+ ID: prefixedID,
+ Name: track.Name,
+ Artists: track.Artists,
+ AlbumName: track.AlbumName,
+ AlbumArtist: track.AlbumArtist,
+ DurationMS: track.DurationMS,
+ CoverURL: track.Images,
+ Images: track.Images,
+ ReleaseDate: track.ReleaseDate,
+ TrackNumber: track.TrackNumber,
+ DiscNumber: track.DiscNumber,
+ ISRC: track.ISRC,
+ ProviderID: providerID,
+ SpotifyID: prefixedID,
+ DeezerID: deezerID,
+ TidalID: tidalID,
+ QobuzID: qobuzID,
+ AlbumType: track.AlbumType,
+ }
+}
+
+func metadataTrackDedupKey(track ExtTrackMetadata) string {
+ if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
+ return "isrc:" + strings.ToUpper(isrc)
+ }
+ if spotifyID := strings.TrimSpace(track.SpotifyID); spotifyID != "" {
+ return "spotify:" + spotifyID
+ }
+ if providerID := strings.TrimSpace(track.ProviderID); providerID != "" && strings.TrimSpace(track.ID) != "" {
+ return providerID + ":" + strings.TrimSpace(track.ID)
+ }
+ return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists)
+}
+
+func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
+ switch providerID {
+ case "deezer":
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
+ if err != nil {
+ return nil, err
+ }
+
+ tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
+ for _, track := range results.Tracks {
+ tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
+ }
+ return tracks, nil
+ case "qobuz":
+ return NewQobuzDownloader().SearchTracks(query, limit)
+ case "tidal":
+ return NewTidalDownloader().SearchTracks(query, limit)
+ default:
+ return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID)
+ }
+}
+
+func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
+ priority := GetMetadataProviderPriority()
+ if limit <= 0 {
+ limit = 20
+ }
+
+ extensionProviders := make(map[string]*ExtensionProviderWrapper)
+ if includeExtensions {
+ for _, provider := range m.GetMetadataProviders() {
+ extensionProviders[provider.extension.ID] = provider
+ }
+ }
+
+ orderedProviderIDs := make([]string, 0, len(priority)+len(extensionProviders))
+ seenProviderIDs := make(map[string]struct{}, len(priority)+len(extensionProviders))
+ for _, providerID := range priority {
+ providerID = strings.TrimSpace(providerID)
+ if providerID == "" {
+ continue
+ }
+ orderedProviderIDs = append(orderedProviderIDs, providerID)
+ seenProviderIDs[providerID] = struct{}{}
+ }
+ if includeExtensions {
+ remainingIDs := make([]string, 0, len(extensionProviders))
+ for providerID := range extensionProviders {
+ if _, exists := seenProviderIDs[providerID]; exists {
+ continue
+ }
+ remainingIDs = append(remainingIDs, providerID)
+ }
+ sort.Strings(remainingIDs)
+ orderedProviderIDs = append(orderedProviderIDs, remainingIDs...)
+ }
+
+ tracks := make([]ExtTrackMetadata, 0, limit)
+ seenTracks := make(map[string]struct{})
+ for _, providerID := range orderedProviderIDs {
+ var (
+ providerTracks []ExtTrackMetadata
+ err error
+ )
+
+ if isBuiltInProvider(providerID) {
+ providerTracks, err = searchBuiltInMetadataTracksFunc(providerID, query, limit)
+ } else {
+ if !includeExtensions {
+ continue
+ }
+ provider := extensionProviders[providerID]
+ if provider == nil {
+ continue
+ }
+ var result *ExtSearchResult
+ result, err = provider.SearchTracks(query, limit)
+ if result != nil {
+ providerTracks = result.Tracks
+ }
+ }
+
+ if err != nil {
+ GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err)
+ continue
+ }
+
+ for _, track := range providerTracks {
+ key := metadataTrackDedupKey(track)
+ if key == "" {
+ continue
+ }
+ if _, exists := seenTracks[key]; exists {
+ continue
+ }
+ seenTracks[key] = struct{}{}
+ tracks = append(tracks, track)
+ if len(tracks) >= limit {
+ return tracks, nil
+ }
+ }
+ }
+
+ return tracks, nil
+}
+
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
priority := GetProviderPriority()
extManager := GetExtensionManager()
@@ -765,6 +977,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if enrichedTrack.Artists != "" {
req.ArtistName = enrichedTrack.Artists
}
+ if enrichedTrack.AlbumName != "" && req.AlbumName == "" {
+ GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName)
+ req.AlbumName = enrichedTrack.AlbumName
+ }
+ if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" {
+ req.AlbumArtist = enrichedTrack.AlbumArtist
+ }
+ if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 {
+ GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS)
+ req.DurationMS = enrichedTrack.DurationMS
+ }
+ if enrichedTrack.CoverURL != "" && req.CoverURL == "" {
+ req.CoverURL = enrichedTrack.CoverURL
+ }
+ if enrichedTrack.ID != "" && req.SpotifyID == "" {
+ GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID)
+ req.SpotifyID = enrichedTrack.ID
+ }
if enrichedTrack.Label != "" && req.Label == "" {
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
req.Label = enrichedTrack.Label
@@ -785,6 +1015,77 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
+ // If key metadata is still missing after extension enrichment, search
+ // configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
+ // logic that ReEnrichFile uses.
+ if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
+ req.TrackName != "" && req.ArtistName != "" &&
+ (req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
+
+ searchQuery := req.TrackName + " " + req.ArtistName
+ GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
+
+ tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
+ if searchErr == nil && len(tracks) > 0 {
+ track := tracks[0]
+ GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
+ track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
+
+ if track.AlbumName != "" && req.AlbumName == "" {
+ req.AlbumName = track.AlbumName
+ }
+ if track.AlbumArtist != "" && req.AlbumArtist == "" {
+ req.AlbumArtist = track.AlbumArtist
+ }
+ if track.ReleaseDate != "" && req.ReleaseDate == "" {
+ req.ReleaseDate = track.ReleaseDate
+ }
+ if track.ISRC != "" && req.ISRC == "" {
+ req.ISRC = track.ISRC
+ }
+ if track.TrackNumber > 0 && req.TrackNumber == 0 {
+ req.TrackNumber = track.TrackNumber
+ }
+ if track.DiscNumber > 0 && req.DiscNumber == 0 {
+ req.DiscNumber = track.DiscNumber
+ }
+ if track.CoverURL != "" && req.CoverURL == "" {
+ req.CoverURL = track.CoverURL
+ }
+ if track.Genre != "" && req.Genre == "" {
+ req.Genre = track.Genre
+ }
+ if track.Label != "" && req.Label == "" {
+ req.Label = track.Label
+ }
+ if track.Copyright != "" && req.Copyright == "" {
+ req.Copyright = track.Copyright
+ }
+ } else if searchErr != nil {
+ GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
+ }
+
+ // Try Deezer extended metadata if we have ISRC
+ if req.ISRC != "" &&
+ (req.Genre == "" || req.Label == "" || req.Copyright == "") {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
+ cancel()
+ if err == nil && extMeta != nil {
+ if req.Genre == "" && extMeta.Genre != "" {
+ req.Genre = extMeta.Genre
+ }
+ if req.Label == "" && extMeta.Label != "" {
+ req.Label = extMeta.Label
+ }
+ if req.Copyright == "" && extMeta.Copyright != "" {
+ req.Copyright = extMeta.Copyright
+ }
+ GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
+ }
+ }
+ }
+
if req.Source != "" &&
!isBuiltInProvider(strings.ToLower(req.Source)) &&
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
@@ -878,6 +1179,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
+ // Always pass enriched metadata from req so Flutter can
+ // embed it — fills gaps from metadata provider search.
+ if req.AlbumName != "" && resp.Album == "" {
+ resp.Album = req.AlbumName
+ }
+ if req.AlbumArtist != "" && resp.AlbumArtist == "" {
+ resp.AlbumArtist = req.AlbumArtist
+ }
+ if req.ReleaseDate != "" && resp.ReleaseDate == "" {
+ resp.ReleaseDate = req.ReleaseDate
+ }
+ if req.ISRC != "" && resp.ISRC == "" {
+ resp.ISRC = req.ISRC
+ }
+ if req.TrackNumber > 0 && resp.TrackNumber == 0 {
+ resp.TrackNumber = req.TrackNumber
+ }
+ if req.DiscNumber > 0 && resp.DiscNumber == 0 {
+ resp.DiscNumber = req.DiscNumber
+ }
+ if req.CoverURL != "" && resp.CoverURL == "" {
+ resp.CoverURL = req.CoverURL
+ }
+
return resp, nil
}
@@ -928,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerIDNormalized) {
- if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
+ if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
+ req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
deezerClient := GetDeezerClient()
@@ -943,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
+ if req.Copyright == "" && extMeta.Copyright != "" {
+ req.Copyright = extMeta.Copyright
+ GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
+ }
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
@@ -1150,6 +1480,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
+ CoverURL: qobuzResult.CoverURL,
}
}
err = qobuzErr
@@ -1192,6 +1523,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber,
ISRC: result.ISRC,
+ CoverURL: result.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
@@ -1431,6 +1763,12 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
}
}
+ for i := range handleResult.Artist.Releases {
+ handleResult.Artist.Releases[i].ProviderID = p.extension.ID
+ for j := range handleResult.Artist.Releases[i].Tracks {
+ handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
+ }
+ }
for i := range handleResult.Artist.TopTracks {
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
}
diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go
new file mode 100644
index 00000000..3112de44
--- /dev/null
+++ b/go_backend/extension_providers_test.go
@@ -0,0 +1,68 @@
+package gobackend
+
+import "testing"
+
+func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
+ original := GetMetadataProviderPriority()
+ defer SetMetadataProviderPriority(original)
+
+ SetMetadataProviderPriority([]string{"tidal"})
+ got := GetMetadataProviderPriority()
+ want := []string{"tidal", "deezer", "qobuz"}
+ if len(got) != len(want) {
+ t.Fatalf("unexpected priority length: got %v want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
+ }
+ }
+}
+
+func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
+ originalPriority := GetMetadataProviderPriority()
+ originalSearch := searchBuiltInMetadataTracksFunc
+ defer func() {
+ SetMetadataProviderPriority(originalPriority)
+ searchBuiltInMetadataTracksFunc = originalSearch
+ }()
+
+ SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
+
+ var calls []string
+ searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
+ calls = append(calls, providerID)
+ switch providerID {
+ case "qobuz":
+ return []ExtTrackMetadata{
+ {ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
+ }, nil
+ case "tidal":
+ return []ExtTrackMetadata{
+ {ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
+ {ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
+ }, nil
+ case "deezer":
+ return []ExtTrackMetadata{
+ {ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
+ }, nil
+ default:
+ return nil, nil
+ }
+ }
+
+ manager := GetExtensionManager()
+ tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
+ if err != nil {
+ t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
+ }
+ if len(tracks) != 3 {
+ t.Fatalf("unexpected track count: got %d want 3", len(tracks))
+ }
+ if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
+ t.Fatalf("unexpected track provider order: %+v", tracks)
+ }
+ if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
+ t.Fatalf("unexpected provider call order: %v", calls)
+ }
+}
diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go
index c45b52c1..58068a42 100644
--- a/go_backend/extension_runtime.go
+++ b/go_backend/extension_runtime.go
@@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
}
type ExtensionRuntime struct {
- extensionID string
- manifest *ExtensionManifest
- settings map[string]interface{}
- httpClient *http.Client
- cookieJar http.CookieJar
- dataDir string
- vm *goja.Runtime
+ extensionID string
+ manifest *ExtensionManifest
+ settings map[string]interface{}
+ httpClient *http.Client
+ downloadClient *http.Client
+ cookieJar http.CookieJar
+ dataDir string
+ vm *goja.Runtime
storageMu sync.RWMutex
storageCache map[string]interface{}
@@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
storageFlushDelay: defaultStorageFlushDelay,
}
+ runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
+ runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
+
+ return runtime
+}
+
+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.
// spotify-web) will redirect http -> https and can end up in 301 loops.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
client := &http.Client{
Transport: sharedTransport,
- Timeout: 30 * time.Second,
+ Timeout: timeout,
Jar: jar,
}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
@@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
}
return nil
}
- runtime.httpClient = client
-
- return runtime
+ return client
}
type RedirectBlockedError struct {
diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go
index 414f174c..92312c98 100644
--- a/go_backend/extension_runtime_file.go
+++ b/go_backend/extension_runtime_file.go
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
}
- resp, err := r.httpClient.Do(req)
+ client := r.downloadClient
+ if client == nil {
+ client = r.httpClient
+ }
+
+ resp, err := client.Do(req)
if err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go
index 2fa17e84..22ac2d0a 100644
--- a/go_backend/extension_store.go
+++ b/go_backend/extension_store.go
@@ -8,6 +8,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "strings"
"sync"
"time"
)
@@ -129,9 +130,8 @@ var (
)
const (
- defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
- cacheTTL = 30 * time.Minute
- cacheFileName = "store_cache.json"
+ cacheTTL = 30 * time.Minute
+ cacheFileName = "store_cache.json"
)
func InitExtensionStore(cacheDir string) *ExtensionStore {
@@ -140,7 +140,7 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
if extensionStore == nil {
extensionStore = &ExtensionStore{
- registryURL: defaultRegistryURL,
+ registryURL: "", // No default - user must provide a registry URL
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
@@ -149,6 +149,36 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
return extensionStore
}
+// 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) {
+ s.cacheMu.Lock()
+ defer s.cacheMu.Unlock()
+
+ if s.registryURL == registryURL {
+ return
+ }
+
+ s.registryURL = registryURL
+ s.cache = nil
+ s.cacheTime = time.Time{}
+
+ // Clear disk cache since it's from a different registry
+ if s.cacheDir != "" {
+ cachePath := filepath.Join(s.cacheDir, cacheFileName)
+ os.Remove(cachePath)
+ }
+
+ LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
+}
+
+// GetRegistryURL returns the currently configured registry URL.
+func (s *ExtensionStore) GetRegistryURL() string {
+ s.cacheMu.RLock()
+ defer s.cacheMu.RUnlock()
+ return s.registryURL
+}
+
func GetExtensionStore() *ExtensionStore {
extensionStoreMu.Lock()
defer extensionStoreMu.Unlock()
@@ -206,6 +236,11 @@ 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")
+ }
+
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
return s.cache, nil
@@ -336,6 +371,81 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
return nil
}
+// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
+//
+// Accepted formats:
+// - https://raw.githubusercontent.com/owner/repo//registry.json → returned as-is
+// - 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) {
+ input = strings.TrimSpace(input)
+ if input == "" {
+ return "", fmt.Errorf("registry URL is empty")
+ }
+
+ // Already a fully-qualified raw URL – keep it.
+ if strings.Contains(input, "raw.githubusercontent.com") {
+ 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.
+ const ghPrefixHTTP = "http://github.com/"
+ if strings.HasPrefix(input, ghPrefixHTTP) {
+ input = "https://github.com/" + input[len(ghPrefixHTTP):]
+ } else {
+ // Not a GitHub URL – return as-is.
+ return input, nil
+ }
+ }
+
+ path := input[len(ghPrefix):]
+ parts := strings.SplitN(path, "/", 3) // owner, repo, [rest]
+ if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
+ return "", fmt.Errorf("invalid GitHub URL: expected github.com//")
+ }
+ owner := parts[0]
+ repo := strings.TrimSuffix(parts[1], ".git")
+
+ branch := resolveGitHubDefaultBranch(owner, repo)
+
+ resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
+ LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
+ return resolved, nil
+}
+
+// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
+// default branch. Falls back to "main" on any error.
+func resolveGitHubDefaultBranch(owner, repo string) string {
+ apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
+ client := NewHTTPClientWithTimeout(10 * time.Second)
+
+ resp, err := client.Get(apiURL)
+ if err != nil {
+ LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
+ return "main"
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
+ return "main"
+ }
+
+ var info struct {
+ DefaultBranch string `json:"default_branch"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
+ LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
+ return "main"
+ }
+
+ return info.DefaultBranch
+}
+
func requireHTTPSURL(rawURL string, context string) error {
if rawURL == "" {
return fmt.Errorf("%s URL is empty", context)
@@ -374,12 +484,10 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
queryLower := toLower(query)
for _, ext := range extensions {
- // Filter by category
if category != "" && ext.Category != category {
continue
}
- // Filter by query
if query != "" {
if !containsIgnoreCase(ext.Name, queryLower) &&
!containsIgnoreCase(ext.DisplayName, queryLower) &&
diff --git a/go_backend/httputil.go b/go_backend/httputil.go
index b54ec489..05e4af8f 100644
--- a/go_backend/httputil.go
+++ b/go_backend/httputil.go
@@ -31,7 +31,7 @@ func getRandomUserAgent() string {
const (
DefaultTimeout = 60 * time.Second
- DownloadTimeout = 120 * time.Second
+ DownloadTimeout = 24 * time.Hour
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
@@ -346,11 +346,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
return min(nextDelay, config.MaxDelay)
}
-// Returns 60 seconds as default if header is missing or invalid
+// Returns 0 if the header is missing or invalid so callers can keep their
+// normal exponential backoff instead of stalling for an arbitrary minute.
func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter == "" {
- return 60 * time.Second // Default wait time
+ return 0
}
if seconds, err := strconv.Atoi(retryAfter); err == nil {
@@ -364,7 +365,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
}
}
- return 60 * time.Second // Default
+ return 0
}
func ReadResponseBody(resp *http.Response) ([]byte, error) {
diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go
index d9e53663..ee8874d1 100644
--- a/go_backend/library_scan.go
+++ b/go_backend/library_scan.go
@@ -1,10 +1,12 @@
package gobackend
import (
+ "bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
+ "strconv"
"strings"
"sync"
"time"
@@ -63,6 +65,7 @@ var supportedAudioFormats = map[string]bool{
".mp3": true,
".opus": true,
".ogg": true,
+ ".cue": true,
}
type libraryAudioFileInfo struct {
@@ -70,6 +73,11 @@ type libraryAudioFileInfo struct {
modTime int64
}
+type scannedCueFileInfo struct {
+ sheet *CueSheet
+ audioPath string
+}
+
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
@@ -143,12 +151,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
return "[]", err
}
- audioFiles := make([]string, 0, len(audioFileInfos))
- for _, fileInfo := range audioFileInfos {
- audioFiles = append(audioFiles, fileInfo.path)
- }
-
- totalFiles := len(audioFiles)
+ totalFiles := len(audioFileInfos)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
@@ -166,7 +169,31 @@ func ScanLibraryFolder(folderPath string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
- for i, filePath := range audioFiles {
+ // Track audio files referenced by .cue sheets to avoid duplicates
+ cueReferencedAudioFiles := make(map[string]bool)
+ parsedCueFiles := make(map[string]scannedCueFileInfo)
+
+ // First pass: scan .cue files to collect referenced audio paths
+ for _, fileInfo := range audioFileInfos {
+ filePath := fileInfo.path
+ ext := strings.ToLower(filepath.Ext(filePath))
+ if ext == ".cue" {
+ sheet, err := ParseCueFile(filePath)
+ if err == nil && sheet.FileName != "" {
+ audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
+ if audioPath != "" {
+ parsedCueFiles[filePath] = scannedCueFileInfo{
+ sheet: sheet,
+ audioPath: audioPath,
+ }
+ cueReferencedAudioFiles[audioPath] = true
+ }
+ }
+ }
+ }
+
+ for i, fileInfo := range audioFileInfos {
+ filePath := fileInfo.path
select {
case <-cancelCh:
return "[]", fmt.Errorf("scan cancelled")
@@ -179,7 +206,42 @@ func ScanLibraryFolder(folderPath string) (string, error) {
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
- result, err := scanAudioFile(filePath, scanTime)
+ ext := strings.ToLower(filepath.Ext(filePath))
+
+ // Handle .cue files: produce multiple track results
+ if ext == ".cue" {
+ var cueResults []LibraryScanResult
+ cueInfo, ok := parsedCueFiles[filePath]
+ if ok {
+ cueResults, err = scanCueSheetForLibrary(
+ filePath,
+ cueInfo.sheet,
+ cueInfo.audioPath,
+ "",
+ fileInfo.modTime,
+ scanTime,
+ )
+ } else {
+ cueResults, err = ScanCueFileForLibrary(filePath, scanTime)
+ }
+ if err != nil {
+ errorCount++
+ GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
+ continue
+ }
+ results = append(results, cueResults...)
+ GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
+ 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
+ }
+
+ result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
@@ -205,7 +267,15 @@ func ScanLibraryFolder(folderPath string) (string, error) {
}
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
- ext := strings.ToLower(filepath.Ext(filePath))
+ return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
+}
+
+func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
+ return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
+}
+
+func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
+ ext := resolveLibraryAudioExt(filePath, displayNameHint)
result := &LibraryScanResult{
ID: generateLibraryID(filePath),
@@ -214,15 +284,17 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
Format: strings.TrimPrefix(ext, "."),
}
- if info, err := os.Stat(filePath); err == nil {
+ if knownModTime > 0 {
+ result.FileModTime = knownModTime
+ } else if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
- if coverCacheDir != "" && ext != ".m4a" {
- coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
+ if coverCacheDir != "" {
+ coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
@@ -236,15 +308,31 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
case ".mp3":
return scanMP3File(filePath, result)
case ".opus", ".ogg":
- return scanOggFile(filePath, result)
+ return scanOggFile(filePath, result, displayNameHint)
default:
- return scanFromFilename(filePath, result)
+ return scanFromFilename(filePath, displayNameHint, result)
}
}
-func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
+func resolveLibraryAudioExt(filePath, displayNameHint string) string {
+ ext := strings.ToLower(filepath.Ext(filePath))
+ if ext != "" {
+ return ext
+ }
+ return strings.ToLower(filepath.Ext(displayNameHint))
+}
+
+func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
+ if displayNameHint != "" {
+ return displayNameHint
+ }
+ return filePath
+}
+
+func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
+ nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
if result.TrackName == "" {
- result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
+ result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
@@ -257,7 +345,7 @@ func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
- return scanFromFilename(filePath, result)
+ return scanFromFilename(filePath, "", result)
}
result.TrackName = metadata.Title
@@ -279,26 +367,43 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
}
}
- applyDefaultLibraryMetadata(filePath, result)
+ applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
+ metadata, err := ReadM4ATags(filePath)
+ if err == nil && metadata != nil {
+ result.TrackName = metadata.Title
+ result.ArtistName = metadata.Artist
+ result.AlbumName = metadata.Album
+ result.AlbumArtist = metadata.AlbumArtist
+ result.ISRC = metadata.ISRC
+ result.TrackNumber = metadata.TrackNumber
+ result.DiscNumber = metadata.DiscNumber
+ result.ReleaseDate = metadata.Date
+ if result.ReleaseDate == "" {
+ result.ReleaseDate = metadata.Year
+ }
+ result.Genre = metadata.Genre
+ }
+
quality, err := GetM4AQuality(filePath)
if err == nil {
result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate
}
- return scanFromFilename(filePath, result)
+ applyDefaultLibraryMetadata(filePath, "", result)
+ return result, nil
}
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
- return scanFromFilename(filePath, result)
+ return scanFromFilename(filePath, "", result)
}
result.TrackName = metadata.Title
@@ -325,16 +430,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
}
}
- applyDefaultLibraryMetadata(filePath, result)
+ applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
}
-func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
+func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
- return scanFromFilename(filePath, result)
+ return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
@@ -357,13 +462,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
}
}
- applyDefaultLibraryMetadata(filePath, result)
+ applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
-func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
- filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
+func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
+ nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
+ filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
parts := strings.SplitN(filename, " - ", 2)
if len(parts) == 2 {
@@ -386,7 +492,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
dir := filepath.Dir(filePath)
result.AlbumName = filepath.Base(dir)
- if result.AlbumName == "." || result.AlbumName == "" {
+ if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
result.AlbumName = "Unknown Album"
}
@@ -433,8 +539,12 @@ func CancelLibraryScan() {
}
func ReadAudioMetadata(filePath string) (string, error) {
+ return ReadAudioMetadataWithDisplayName(filePath, "")
+}
+
+func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
- result, err := scanAudioFile(filePath, scanTime)
+ result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
if err != nil {
return "", err
}
@@ -450,7 +560,43 @@ func ReadAudioMetadata(filePath string) (string, error) {
// 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 ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
+func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
+ existingFiles := make(map[string]int64)
+ if snapshotPath == "" {
+ return existingFiles, nil
+ }
+
+ file, err := os.Open(snapshotPath)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "\t", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ modTime, err := strconv.ParseInt(parts[0], 10, 64)
+ if err != nil {
+ continue
+ }
+ existingFiles[parts[1]] = modTime
+ }
+
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ return existingFiles, nil
+}
+
+func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
@@ -463,13 +609,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
- existingFiles := make(map[string]int64)
- if existingFilesJSON != "" && existingFilesJSON != "{}" {
- if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
- GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
- }
- }
-
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
libraryScanProgressMu.Lock()
@@ -501,10 +640,31 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
// Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo
skippedCount := 0
+ existingCueTrackModTimes := make(map[string]int64)
+ for existingPath, modTime := range existingFiles {
+ if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
+ baseCuePath := existingPath[:idx]
+ if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
+ existingCueTrackModTimes[baseCuePath] = modTime
+ }
+ }
+ }
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 {
+ filesToScan = append(filesToScan, f)
+ }
+ continue
+ }
+ }
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
filesToScan = append(filesToScan, f)
@@ -515,7 +675,16 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
var deletedPaths []string
for existingPath := range existingFiles {
- if !currentPathSet[existingPath] {
+ // 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
+ }
+ // Base CUE file is gone, mark virtual path as deleted
+ deletedPaths = append(deletedPaths, existingPath)
+ } else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
@@ -544,6 +713,26 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
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 {
+ ext := strings.ToLower(filepath.Ext(f.path))
+ if ext == ".cue" {
+ sheet, err := ParseCueFile(f.path)
+ if err == nil && sheet.FileName != "" {
+ audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
+ if audioPath != "" {
+ parsedCueFiles[f.path] = scannedCueFileInfo{
+ sheet: sheet,
+ audioPath: audioPath,
+ }
+ cueReferencedAudioFilesInc[audioPath] = true
+ }
+ }
+ }
+ }
+
for i, f := range filesToScan {
select {
case <-cancelCh:
@@ -557,7 +746,39 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
- result, err := scanAudioFile(f.path, scanTime)
+ 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]
+ if ok {
+ cueResults, err = scanCueSheetForLibrary(
+ f.path,
+ cueInfo.sheet,
+ cueInfo.audioPath,
+ "",
+ f.modTime,
+ scanTime,
+ )
+ } else {
+ cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
+ }
+ if err != nil {
+ errorCount++
+ GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
+ continue
+ }
+ results = append(results, cueResults...)
+ continue
+ }
+
+ // Skip audio files referenced by .cue sheets
+ if cueReferencedAudioFilesInc[f.path] {
+ continue
+ }
+
+ result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
@@ -591,3 +812,24 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON 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 ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
+ existingFiles := make(map[string]int64)
+ if existingFilesJSON != "" && existingFilesJSON != "{}" {
+ if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
+ GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
+ }
+ }
+ return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
+}
+
+func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
+ existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
+ if err != nil {
+ return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
+ }
+ return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
+}
diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go
index 1a6c4771..60d3aa85 100644
--- a/go_backend/lyrics.go
+++ b/go_backend/lyrics.go
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"fmt"
+ "io"
"math"
"net/http"
"net/url"
@@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string {
// 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 synced lyrics via community API"},
+ {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
- {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
- {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
- {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
- {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
+ {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
+ {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
+ {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
+ {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
}
}
@@ -431,6 +432,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
return now.Add(10 * time.Minute)
}
+func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
+ if len(lines) == 0 {
+ return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
+ }
+ if syncType == "" {
+ if len(lines) > 0 && lines[0].StartTimeMs > 0 {
+ syncType = "LINE_SYNCED"
+ } else {
+ syncType = "UNSYNCED"
+ }
+ }
+ return &LyricsResponse{
+ Lines: lines,
+ SyncType: syncType,
+ Instrumental: false,
+ PlainLyrics: plainLyrics,
+ Provider: "Spotify Lyrics API",
+ Source: "Spotify Lyrics API",
+ }, nil
+}
+
+func plainLyricsFromTimedLines(lines []LyricsLine) string {
+ parts := make([]string, 0, len(lines))
+ for _, line := range lines {
+ words := strings.TrimSpace(line.Words)
+ if words == "" {
+ continue
+ }
+ parts = append(parts, words)
+ }
+ return strings.Join(parts, "\n")
+}
+
+func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
+ var lrcPayload string
+ if err := json.Unmarshal(body, &lrcPayload); err == nil {
+ trimmed := strings.TrimSpace(lrcPayload)
+ if trimmed == "" {
+ return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
+ }
+
+ lines := parseSyncedLyrics(trimmed)
+ if len(lines) > 0 {
+ return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
+ }
+
+ plainLines := plainTextLyricsLines(trimmed)
+ return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
+ }
+
+ var apiResp SpotifyLyricsAPIResponse
+ if err := json.Unmarshal(body, &apiResp); err != nil {
+ return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
+ }
+
+ if apiResp.Error {
+ msg := strings.TrimSpace(apiResp.Message)
+ if msg == "" {
+ msg = "Spotify Lyrics API returned error"
+ }
+ return nil, fmt.Errorf("%s", msg)
+ }
+
+ lines := make([]LyricsLine, 0, len(apiResp.Lines))
+ for _, line := range apiResp.Lines {
+ words := strings.TrimSpace(line.Words)
+ if words == "" {
+ continue
+ }
+ startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
+ lines = append(lines, LyricsLine{
+ StartTimeMs: startMs,
+ Words: words,
+ EndTimeMs: 0,
+ })
+ }
+
+ for i := 0; i < len(lines)-1; i++ {
+ nextStart := lines[i+1].StartTimeMs
+ if nextStart > lines[i].StartTimeMs {
+ lines[i].EndTimeMs = nextStart
+ }
+ }
+ if len(lines) > 0 {
+ last := len(lines) - 1
+ if lines[last].EndTimeMs == 0 {
+ lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
+ }
+ }
+
+ return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
+}
+
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
now := time.Now()
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
@@ -449,7 +543,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
spotifyID = parsed.ID
}
- apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
+ apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -462,13 +556,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
}
defer resp.Body.Close()
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
+ }
+
if resp.StatusCode != 200 {
if resp.StatusCode == http.StatusTooManyRequests {
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
setSpotifyLyricsRateLimitUntil(retryUntil)
}
var payload map[string]interface{}
- if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
+ if err := json.Unmarshal(bodyBytes, &payload); err == nil {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
}
@@ -479,63 +578,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
}
- var apiResp SpotifyLyricsAPIResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
- return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
- }
-
- if apiResp.Error {
- msg := strings.TrimSpace(apiResp.Message)
- if msg == "" {
- msg = "Spotify Lyrics API returned error"
- }
- return nil, fmt.Errorf("%s", msg)
- }
-
- result := &LyricsResponse{
- Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
- SyncType: apiResp.SyncType,
- Instrumental: false,
- PlainLyrics: "",
- Provider: "Spotify Lyrics API",
- Source: "Spotify Lyrics API",
- }
-
- for _, line := range apiResp.Lines {
- words := strings.TrimSpace(line.Words)
- if words == "" {
- continue
- }
- startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
- result.Lines = append(result.Lines, LyricsLine{
- StartTimeMs: startMs,
- Words: words,
- EndTimeMs: 0,
- })
- }
-
- if len(result.Lines) > 1 {
- for i := 0; i < len(result.Lines)-1; i++ {
- nextStart := result.Lines[i+1].StartTimeMs
- if nextStart > result.Lines[i].StartTimeMs {
- result.Lines[i].EndTimeMs = nextStart
- }
- }
- last := len(result.Lines) - 1
- if result.Lines[last].EndTimeMs == 0 {
- result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
- }
- }
-
- if len(result.Lines) == 0 {
- return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
- }
-
- if result.SyncType == "" {
- result.SyncType = "LINE_SYNCED"
- }
-
- return result, nil
+ return parseSpotifyLyricsResponseBody(bodyBytes)
}
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go
index 538b0b89..8fe350ad 100644
--- a/go_backend/lyrics_apple.go
+++ b/go_backend/lyrics_apple.go
@@ -4,121 +4,25 @@ import (
"encoding/json"
"fmt"
"io"
+ "math"
"net/http"
"net/url"
- "regexp"
"strings"
- "sync"
"time"
)
// AppleMusicClient fetches lyrics from Apple Music.
-// Uses a scraped JWT token for search and a proxy for lyrics.
+// Uses Paxsenix endpoints for search and lyrics.
type AppleMusicClient struct {
httpClient *http.Client
}
-// Apple Music token manager — singleton with mutex for thread safety
-type appleTokenManager struct {
- mu sync.Mutex
- token string
-}
-
-var globalAppleTokenManager = &appleTokenManager{}
-
-func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
-
- if m.token != "" {
- return m.token, nil
- }
-
- // Step 1: Fetch the Apple Music beta page
- req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
- if err != nil {
- return "", fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("User-Agent", getRandomUserAgent())
-
- resp, err := client.Do(req)
- if err != nil {
- return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", fmt.Errorf("failed to read Apple Music page: %w", err)
- }
-
- // Step 2: Find the index JS file URL
- indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
- match := indexJsRegex.Find(body)
- if match == nil {
- return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
- }
-
- indexJsURL := "https://beta.music.apple.com" + string(match)
-
- // Step 3: Fetch the JS file
- jsReq, err := http.NewRequest("GET", indexJsURL, nil)
- if err != nil {
- return "", fmt.Errorf("failed to create JS request: %w", err)
- }
- jsReq.Header.Set("User-Agent", getRandomUserAgent())
-
- jsResp, err := client.Do(jsReq)
- if err != nil {
- return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
- }
- defer jsResp.Body.Close()
-
- jsBody, err := io.ReadAll(jsResp.Body)
- if err != nil {
- return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
- }
-
- // Step 4: Extract JWT token (starts with eyJh)
- tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
- tokenMatch := tokenRegex.Find(jsBody)
- if tokenMatch == nil {
- return "", fmt.Errorf("could not find JWT token in Apple Music JS")
- }
-
- m.token = string(tokenMatch)
- GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
- return m.token, nil
-}
-
-func (m *appleTokenManager) clearToken() {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.token = ""
-}
-
-type appleMusicSearchResponse struct {
- Results struct {
- Songs *struct {
- Data []struct {
- ID string `json:"id"`
- Type string `json:"type"`
- } `json:"data"`
- } `json:"songs"`
- } `json:"results"`
- Resources *struct {
- Songs map[string]struct {
- Attributes struct {
- Name string `json:"name"`
- ArtistName string `json:"artistName"`
- AlbumName string `json:"albumName"`
- URL string `json:"url"`
- Artwork struct {
- URL string `json:"url"`
- } `json:"artwork"`
- } `json:"attributes"`
- } `json:"songs"`
- } `json:"resources"`
+type appleMusicSearchResult struct {
+ ID string `json:"id"`
+ SongName string `json:"songName"`
+ ArtistName string `json:"artistName"`
+ AlbumName string `json:"albumName"`
+ Duration int `json:"duration"`
}
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
@@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
}
}
+func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
+ if len(results) == 0 {
+ return nil
+ }
+
+ normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
+ normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
+ if normalizedArtist == "" {
+ normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
+ }
+
+ bestIndex := 0
+ bestScore := -1
+ for i := range results {
+ result := &results[i]
+ score := 0
+
+ candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
+ candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
+
+ switch {
+ case candidateTrack == normalizedTrack:
+ score += 50
+ case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
+ score += 25
+ }
+
+ switch {
+ case candidateArtist == normalizedArtist:
+ score += 60
+ case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
+ score += 30
+ }
+
+ if durationSec > 0 && result.Duration > 0 {
+ diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
+ if diff <= durationToleranceSec {
+ score += 20
+ }
+ }
+
+ if score > bestScore {
+ bestScore = score
+ bestIndex = i
+ }
+ }
+
+ return &results[bestIndex]
+}
+
// SearchSong searches for a song on Apple Music and returns its ID.
-func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
+func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query")
}
- token, err := globalAppleTokenManager.getToken(c.httpClient)
- if err != nil {
- return "", fmt.Errorf("apple music token error: %w", err)
- }
-
encodedQuery := url.QueryEscape(query)
- searchURL := fmt.Sprintf(
- "https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
- encodedQuery,
- )
+ searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
- req.Header.Set("Authorization", "Bearer "+token)
- req.Header.Set("Origin", "https://music.apple.com")
- req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
}
defer resp.Body.Close()
- if resp.StatusCode == 401 {
- globalAppleTokenManager.clearToken()
- return "", fmt.Errorf("apple music token expired")
- }
-
if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
}
- var searchResp appleMusicSearchResponse
+ var searchResp []appleMusicSearchResult
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err)
}
- if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
+ best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
+ if best == nil || strings.TrimSpace(best.ID) == "" {
return "", fmt.Errorf("no songs found on apple music")
}
- return searchResp.Results.Songs.Data[0].ID, nil
+ return strings.TrimSpace(best.ID), nil
}
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
- songID, err := c.SearchSong(trackName, artistName)
+ songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go
index f962a110..37dfbea7 100644
--- a/go_backend/lyrics_musixmatch.go
+++ b/go_backend/lyrics_musixmatch.go
@@ -3,6 +3,8 @@ package gobackend
import (
"encoding/json"
"fmt"
+ "io"
+ "math"
"net/http"
"net/url"
"strings"
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
func NewMusixmatchClient() *MusixmatchClient {
return &MusixmatchClient{
httpClient: NewMetadataHTTPClient(15 * time.Second),
- baseURL: "http://158.180.60.95",
+ baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
}
}
-// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
-// The Musixmatch proxy returns both search result and lyrics in a single response.
-func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
+func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
- return nil, fmt.Errorf("empty track or artist name")
+ return "", fmt.Errorf("empty track or artist name")
}
- encodedArtist := url.QueryEscape(artistName)
- encodedTrack := url.QueryEscape(trackName)
-
- fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
+ params := url.Values{}
+ params.Set("t", trackName)
+ params.Set("a", artistName)
+ params.Set("type", lyricsType)
+ params.Set("format", "lrc")
+ if durationSec > 0 {
+ params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
+ }
+ if strings.TrimSpace(language) != "" {
+ params.Set("l", strings.ToLower(strings.TrimSpace(language)))
+ }
+ fullURL := c.baseURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
+ return "", fmt.Errorf("failed to create request: %w", err)
}
+ req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
- return nil, fmt.Errorf("musixmatch search failed: %w", err)
+ return "", fmt.Errorf("musixmatch request failed: %w", err)
}
defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read musixmatch response: %w", err)
+ }
+
if resp.StatusCode != 200 {
- return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
+ trimmed := strings.TrimSpace(string(body))
+ if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
+ return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
+ }
+ return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
}
- var result musixmatchSearchResponse
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
+ var lrcPayload string
+ if err := json.Unmarshal(body, &lrcPayload); err == nil {
+ lrcPayload = strings.TrimSpace(lrcPayload)
+ if lrcPayload == "" {
+ return "", fmt.Errorf("empty musixmatch lyrics payload")
+ }
+ return lrcPayload, nil
}
- return &result, nil
+ trimmed := strings.TrimSpace(string(body))
+ if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
+ return "", fmt.Errorf("%s", errMsg)
+ }
+ if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
+ return trimmed, nil
+ }
+ return "", fmt.Errorf("failed to decode musixmatch response")
}
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
-func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
+func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
lang := strings.ToLower(strings.TrimSpace(language))
- if songID <= 0 || lang == "" {
- return nil, fmt.Errorf("invalid song id or language")
+ if lang == "" {
+ return nil, fmt.Errorf("invalid language")
}
- fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
-
- req, err := http.NewRequest("GET", fullURL, nil)
+ lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("User-Agent", getRandomUserAgent())
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != 200 {
- return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
+ return nil, err
}
- var result musixmatchSearchResponse
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
+ lines := parseSyncedLyrics(lrcText)
+ if len(lines) > 0 {
+ return &LyricsResponse{
+ Lines: lines,
+ SyncType: "LINE_SYNCED",
+ PlainLyrics: plainLyricsFromTimedLines(lines),
+ Provider: "Musixmatch",
+ Source: fmt.Sprintf("Musixmatch (%s)", lang),
+ }, nil
}
- if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
- lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
- if len(lines) > 0 {
- return &LyricsResponse{
- Lines: lines,
- SyncType: "LINE_SYNCED",
- Provider: "Musixmatch",
- Source: fmt.Sprintf("Musixmatch (%s)", lang),
- }, nil
- }
- }
-
- if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
- lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
-
- if len(lines) > 0 {
- return &LyricsResponse{
- Lines: lines,
- SyncType: "UNSYNCED",
- PlainLyrics: result.UnsyncedLyrics.Lyrics,
- Provider: "Musixmatch",
- Source: fmt.Sprintf("Musixmatch (%s)", lang),
- }, nil
- }
+ plainLines := plainTextLyricsLines(lrcText)
+ if len(plainLines) > 0 {
+ return &LyricsResponse{
+ Lines: plainLines,
+ SyncType: "UNSYNCED",
+ PlainLyrics: lrcText,
+ Provider: "Musixmatch",
+ Source: fmt.Sprintf("Musixmatch (%s)", lang),
+ }, nil
}
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
@@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
- result, err := c.searchAndGetLyrics(trackName, artistName)
- if err != nil {
- return nil, err
- }
-
- if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
- localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
+ if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
+ localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
if localizedErr == nil {
return localized, nil
}
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
}
- if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
- lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
- if len(lines) > 0 {
- return &LyricsResponse{
- Lines: lines,
- SyncType: "LINE_SYNCED",
- Provider: "Musixmatch",
- Source: "Musixmatch",
- }, nil
- }
+ lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
+ if err != nil {
+ return nil, err
}
- if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
- lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
+ lines := parseSyncedLyrics(lrcText)
+ if len(lines) > 0 {
+ return &LyricsResponse{
+ Lines: lines,
+ SyncType: "LINE_SYNCED",
+ PlainLyrics: plainLyricsFromTimedLines(lines),
+ Provider: "Musixmatch",
+ Source: "Musixmatch",
+ }, nil
+ }
- if len(lines) > 0 {
- return &LyricsResponse{
- Lines: lines,
- SyncType: "UNSYNCED",
- PlainLyrics: result.UnsyncedLyrics.Lyrics,
- Provider: "Musixmatch",
- Source: "Musixmatch",
- }, nil
- }
+ plainLines := plainTextLyricsLines(lrcText)
+ if len(plainLines) > 0 {
+ return &LyricsResponse{
+ Lines: plainLines,
+ SyncType: "UNSYNCED",
+ PlainLyrics: lrcText,
+ Provider: "Musixmatch",
+ Source: "Musixmatch",
+ }, nil
}
return nil, fmt.Errorf("no lyrics found on musixmatch")
diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go
index f6ce6b6c..c6741e19 100644
--- a/go_backend/lyrics_netease.go
+++ b/go_backend/lyrics_netease.go
@@ -9,8 +9,7 @@ import (
"time"
)
-// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
-// This is a direct public API — no proxy dependency.
+// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
type NeteaseClient struct {
httpClient *http.Client
}
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return 0, fmt.Errorf("empty search query")
}
- searchURL := "http://music.163.com/api/search/pc"
+ searchURL := "https://lyrics.paxsenix.org/netease/search"
params := url.Values{}
- params.Set("s", query)
- params.Set("type", "1")
- params.Set("limit", "1")
- params.Set("offset", "0")
+ params.Set("q", query)
fullURL := searchURL + "?" + params.Encode()
@@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
- lyricsURL := "http://music.163.com/api/song/lyric"
+ lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
params := url.Values{}
params.Set("id", fmt.Sprintf("%d", songID))
- params.Set("lv", "1")
- params.Set("tv", "1")
- params.Set("rv", "1")
fullURL := lyricsURL + "?" + params.Encode()
diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go
index dde3631d..95461031 100644
--- a/go_backend/lyrics_qqmusic.go
+++ b/go_backend/lyrics_qqmusic.go
@@ -1,45 +1,31 @@
package gobackend
import (
- "bytes"
"encoding/json"
"fmt"
"io"
+ "math"
"net/http"
- "net/url"
"strings"
"time"
)
// QQMusicClient fetches lyrics from QQ Music.
-// Search uses public QQ Music API, lyrics use the paxsenix proxy.
+// Uses Paxsenix metadata lookup for lyrics.
type QQMusicClient struct {
httpClient *http.Client
}
-type qqMusicSearchResponse struct {
- Data struct {
- Song struct {
- List []struct {
- Title string `json:"title"`
- Singer []struct {
- Name string `json:"name"`
- } `json:"singer"`
- Album struct {
- Name string `json:"name"`
- } `json:"album"`
- ID int64 `json:"id"`
- } `json:"list"`
- } `json:"song"`
- } `json:"data"`
+type qqLyricsMetadataRequest struct {
+ Artist []string `json:"artist"`
+ Album string `json:"album,omitempty"`
+ SongID int64 `json:"songid,omitempty"`
+ Title string `json:"title"`
+ Duration int64 `json:"duration,omitempty"`
}
-// QQ Music lyrics request payload for paxsenix proxy
-type qqLyricsPayload struct {
- Artist []string `json:"artist"`
- Album string `json:"album"`
- ID int64 `json:"id"`
- Title string `json:"title"`
+type qqLyricsMetadataResponse struct {
+ Lyrics []paxLyrics `json:"lyrics"`
}
func NewQQMusicClient() *QQMusicClient {
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
}
}
-// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
-func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
- query := trackName + " " + artistName
- if strings.TrimSpace(query) == "" {
- return nil, fmt.Errorf("empty search query")
+// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
+func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
+ payload := qqLyricsMetadataRequest{
+ Artist: []string{artistName},
+ Title: trackName,
+ }
+ if durationSec > 0 {
+ payload.Duration = int64(math.Round(durationSec))
}
- searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
- params := url.Values{}
- params.Set("format", "json")
- params.Set("inCharset", "utf8")
- params.Set("outCharset", "utf8")
- params.Set("platform", "yqq.json")
- params.Set("new_json", "1")
- params.Set("w", query)
-
- fullURL := searchURL + "?" + params.Encode()
-
- req, err := http.NewRequest("GET", fullURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("User-Agent", getRandomUserAgent())
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("qqmusic search failed: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != 200 {
- return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
- }
-
- var searchResp qqMusicSearchResponse
- if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
- return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
- }
-
- if len(searchResp.Data.Song.List) == 0 {
- return nil, fmt.Errorf("no songs found on qqmusic")
- }
-
- song := searchResp.Data.Song.List[0]
-
- var artists []string
- for _, singer := range song.Singer {
- artists = append(artists, singer.Name)
- }
-
- return &qqLyricsPayload{
- Artist: artists,
- Album: song.Album.Name,
- ID: song.ID,
- Title: song.Title,
- }, nil
-}
-
-// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
-func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
- lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
+ lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal payload: %w", err)
}
- req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
+ req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
return bodyStr, nil
}
+func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
+ var response qqLyricsMetadataResponse
+ if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
+ return "", fmt.Errorf("failed to parse qq metadata lyrics response")
+ }
+ if len(response.Lyrics) == 0 {
+ return "", fmt.Errorf("qq metadata lyrics response was empty")
+ }
+ return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
+}
+
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
func (c *QQMusicClient) FetchLyrics(
trackName,
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
durationSec float64,
multiPersonWordByWord bool,
) (*LyricsResponse, error) {
- payload, err := c.searchSong(trackName, artistName)
- if err != nil {
- return nil, err
- }
-
- rawLyrics, err := c.fetchLyricsByPayload(payload)
+ rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
}
- // Try to parse as pax format (word-by-word or line)
- lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
+ lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
- // If pax parsing fails, try to use as direct LRC text
- lrcText = rawLyrics
+ if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
+ lrcText = fallback
+ } else {
+ lrcText = rawLyrics
+ }
}
lines := parseSyncedLyrics(lrcText)
diff --git a/go_backend/metadata.go b/go_backend/metadata.go
index 29ab2d02..fdd856ec 100644
--- a/go_backend/metadata.go
+++ b/go_backend/metadata.go
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
+ if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
+ lyrics, err := extractLyricsFromM4A(filePath)
+ if err == nil && strings.TrimSpace(lyrics) != "" {
+ return lyrics, nil
+ }
+ return extractLyricsFromSidecarLRC(filePath)
+ }
+
if strings.HasSuffix(lower, ".mp3") {
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
@@ -581,6 +589,299 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
+func ReadM4ATags(filePath string) (*AudioMetadata, error) {
+ f, err := os.Open(filePath)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ fi, err := f.Stat()
+ if err != nil {
+ return nil, err
+ }
+
+ ilst, err := findM4AIlstAtom(f, fi.Size())
+ if err != nil {
+ return nil, err
+ }
+
+ metadata := &AudioMetadata{}
+ start := ilst.offset + ilst.headerSize
+ end := ilst.offset + ilst.size
+ for pos := start; pos+8 <= end; {
+ header, err := readAtomHeaderAt(f, pos, fi.Size())
+ if err != nil {
+ return nil, err
+ }
+ if header.size == 0 {
+ header.size = end - pos
+ }
+ if header.size < header.headerSize {
+ return nil, fmt.Errorf("invalid atom size for %s", header.typ)
+ }
+
+ switch header.typ {
+ case "\xa9nam":
+ metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
+ case "\xa9ART":
+ metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
+ case "\xa9alb":
+ metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
+ case "aART":
+ metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
+ case "\xa9day":
+ metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
+ metadata.Year = metadata.Date
+ case "\xa9gen":
+ metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
+ case "\xa9wrt":
+ metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
+ case "\xa9cmt":
+ metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
+ case "cprt":
+ metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
+ case "\xa9lyr":
+ metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
+ case "trkn":
+ metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
+ case "disk":
+ metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
+ case "----":
+ name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
+ if freeformErr == nil {
+ switch strings.ToUpper(strings.TrimSpace(name)) {
+ case "ISRC":
+ metadata.ISRC = value
+ case "LABEL", "ORGANIZATION":
+ metadata.Label = value
+ case "COMMENT":
+ if metadata.Comment == "" {
+ metadata.Comment = value
+ }
+ case "COMPOSER":
+ if metadata.Composer == "" {
+ metadata.Composer = value
+ }
+ case "COPYRIGHT":
+ if metadata.Copyright == "" {
+ metadata.Copyright = value
+ }
+ case "LYRICS", "UNSYNCEDLYRICS":
+ if metadata.Lyrics == "" {
+ metadata.Lyrics = value
+ }
+ }
+ }
+ }
+
+ pos += header.size
+ }
+
+ if metadata.Title == "" &&
+ metadata.Artist == "" &&
+ metadata.Album == "" &&
+ metadata.AlbumArtist == "" &&
+ metadata.Lyrics == "" &&
+ metadata.TrackNumber == 0 &&
+ metadata.DiscNumber == 0 {
+ return nil, fmt.Errorf("no M4A tags found")
+ }
+
+ return metadata, nil
+}
+
+func extractLyricsFromM4A(filePath string) (string, error) {
+ metadata, err := ReadM4ATags(filePath)
+ if err != nil {
+ return "", err
+ }
+ if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
+ return "", fmt.Errorf("no lyrics found in file")
+ }
+ return metadata.Lyrics, nil
+}
+
+func extractCoverFromM4A(filePath string) ([]byte, error) {
+ f, err := os.Open(filePath)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ fi, err := f.Stat()
+ if err != nil {
+ return nil, err
+ }
+ fileSize := fi.Size()
+
+ ilst, err := findM4AIlstAtom(f, fileSize)
+ if err != nil {
+ return nil, err
+ }
+
+ bodyStart := ilst.offset + ilst.headerSize
+ bodySize := ilst.size - ilst.headerSize
+
+ covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
+ if err != nil || !found {
+ return nil, fmt.Errorf("cover atom not found")
+ }
+
+ dataStart := covr.offset + covr.headerSize
+ dataSize := covr.size - covr.headerSize
+
+ dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
+ if err != nil || !found {
+ return nil, fmt.Errorf("data atom not found in cover")
+ }
+
+ // data atom: header + 4 bytes type indicator + 4 bytes locale
+ imgStart := dataAtom.offset + dataAtom.headerSize + 8
+ imgLen := dataAtom.size - dataAtom.headerSize - 8
+ if imgLen <= 0 {
+ return nil, fmt.Errorf("empty cover data")
+ }
+
+ buf := make([]byte, imgLen)
+ if _, err := f.ReadAt(buf, imgStart); err != nil {
+ return nil, err
+ }
+
+ return buf, nil
+}
+
+// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
+// It tries two common layouts:
+// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
+// 2. moov > meta > ilst (some encoders omit the udta wrapper)
+func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
+ moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
+ if err != nil || !found {
+ return atomHeader{}, fmt.Errorf("moov not found")
+ }
+
+ moovBodyStart := moov.offset + moov.headerSize
+ moovBodySize := moov.size - moov.headerSize
+
+ // Path 1: moov > udta > meta > ilst
+ if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
+ udtaBodyStart := udta.offset + udta.headerSize
+ udtaBodySize := udta.size - udta.headerSize
+ if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
+ metaBodyStart := meta.offset + meta.headerSize + 4
+ metaBodySize := meta.size - meta.headerSize - 4
+ if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
+ return ilst, nil
+ }
+ }
+ }
+
+ // Path 2: moov > meta > ilst (no udta wrapper)
+ if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
+ metaBodyStart := meta.offset + meta.headerSize + 4
+ metaBodySize := meta.size - meta.headerSize - 4
+ if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
+ return ilst, nil
+ }
+ }
+
+ return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
+}
+
+func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
+ payloadStart := dataAtom.offset + dataAtom.headerSize + 8
+ payloadLen := dataAtom.size - dataAtom.headerSize - 8
+ if payloadLen <= 0 {
+ return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
+ }
+
+ buf := make([]byte, payloadLen)
+ if _, err := f.ReadAt(buf, payloadStart); err != nil {
+ return nil, err
+ }
+ return buf, nil
+}
+
+func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
+ dataStart := parent.offset + parent.headerSize
+ dataSize := parent.size - parent.headerSize
+
+ dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
+ if err != nil || !found {
+ return nil, fmt.Errorf("data atom not found in %s", parent.typ)
+ }
+ return readM4ADataAtomPayload(f, dataAtom)
+}
+
+func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
+ payload, err := readM4ADataPayload(f, parent, fileSize)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
+}
+
+func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
+ payload, err := readM4ADataPayload(f, parent, fileSize)
+ if err != nil {
+ return 0, err
+ }
+ if len(payload) < 4 {
+ return 0, fmt.Errorf("index payload too short in %s", parent.typ)
+ }
+ return int(binary.BigEndian.Uint16(payload[2:4])), nil
+}
+
+func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
+ start := parent.offset + parent.headerSize
+ end := parent.offset + parent.size
+
+ var nameValue string
+ var dataValue string
+ for pos := start; pos+8 <= end; {
+ header, err := readAtomHeaderAt(f, pos, fileSize)
+ if err != nil {
+ return "", "", err
+ }
+ if header.size == 0 {
+ header.size = end - pos
+ }
+ if header.size < header.headerSize {
+ return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
+ }
+
+ switch header.typ {
+ case "mean":
+ // Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
+ case "name":
+ // The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
+ // It does NOT contain a nested "data" atom, so read the payload directly.
+ payloadStart := header.offset + header.headerSize + 4
+ payloadLen := header.size - header.headerSize - 4
+ if payloadLen > 0 {
+ buf := make([]byte, payloadLen)
+ if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
+ nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
+ }
+ }
+ case "data":
+ payload, payloadErr := readM4ADataAtomPayload(f, header)
+ if payloadErr == nil {
+ dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
+ }
+ }
+
+ pos += header.size
+ }
+
+ if nameValue == "" || dataValue == "" {
+ return "", "", fmt.Errorf("freeform M4A tag incomplete")
+ }
+
+ return nameValue, dataValue, nil
+}
+
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -743,15 +1044,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, err
}
- buf := make([]byte, 24)
+ buf := make([]byte, 32)
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
}
- sampleRate := int(buf[22])<<8 | int(buf[23])
- bitDepth := 16
- if atomType == "alac" {
- bitDepth = 24
+ // AudioSampleEntry layout from the box type field:
+ // [0:4] type ("mp4a"/"alac")
+ // [4:10] SampleEntry.reserved
+ // [10:12] data_reference_index
+ // [12:20] reserved[8]
+ // [20:22] channelcount
+ // [22:24] samplesize (bit depth)
+ // [24:26] pre_defined
+ // [26:28] reserved
+ // [28:32] samplerate (16.16 fixed-point)
+ sampleRate := int(buf[28])<<8 | int(buf[29])
+ bitDepth := int(buf[22])<<8 | int(buf[23])
+ if bitDepth <= 0 {
+ bitDepth = 16
+ if atomType == "alac" {
+ bitDepth = 24
+ }
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
@@ -874,7 +1188,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
- if absolute+24 > fileSize {
+ if absolute+32 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
diff --git a/go_backend/progress.go b/go_backend/progress.go
index 95ba3fde..2117d242 100644
--- a/go_backend/progress.go
+++ b/go_backend/progress.go
@@ -34,10 +34,16 @@ var (
downloadDir string
downloadDirMu sync.RWMutex
- multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
- multiMu sync.RWMutex
+ multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
+ multiMu sync.RWMutex
+ multiProgressDirty = true
+ cachedMultiProgress = "{\"items\":{}}"
)
+func markMultiProgressDirtyLocked() {
+ multiProgressDirty = true
+}
+
func getProgress() DownloadProgress {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -58,13 +64,25 @@ func getProgress() DownloadProgress {
func GetMultiProgress() string {
multiMu.RLock()
- defer multiMu.RUnlock()
+ if !multiProgressDirty {
+ cached := cachedMultiProgress
+ multiMu.RUnlock()
+ return cached
+ }
+ multiMu.RUnlock()
+ multiMu.Lock()
+ defer multiMu.Unlock()
+ if !multiProgressDirty {
+ return cachedMultiProgress
+ }
jsonBytes, err := json.Marshal(multiProgress)
if err != nil {
return "{\"items\":{}}"
}
- return string(jsonBytes)
+ cachedMultiProgress = string(jsonBytes)
+ multiProgressDirty = false
+ return cachedMultiProgress
}
func GetItemProgress(itemID string) string {
@@ -90,6 +108,7 @@ func StartItemProgress(itemID string) {
IsDownloading: true,
Status: "downloading",
}
+ markMultiProgressDirtyLocked()
}
func SetItemBytesTotal(itemID string, total int64) {
@@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) {
if item, ok := multiProgress.Items[itemID]; ok {
item.BytesTotal = total
+ markMultiProgressDirtyLocked()
}
}
@@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) {
if item.BytesTotal > 0 {
item.Progress = float64(received) / float64(item.BytesTotal)
}
+ markMultiProgressDirtyLocked()
}
}
@@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
if item.BytesTotal > 0 {
item.Progress = float64(received) / float64(item.BytesTotal)
}
+ markMultiProgressDirtyLocked()
}
}
@@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) {
item.Progress = 1.0
item.IsDownloading = false
item.Status = "completed"
+ markMultiProgressDirtyLocked()
}
}
@@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
if bytesTotal > 0 {
item.BytesTotal = bytesTotal
}
+ markMultiProgressDirtyLocked()
}
}
@@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) {
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = 1.0
item.Status = "finalizing"
+ markMultiProgressDirtyLocked()
}
}
@@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) {
defer multiMu.Unlock()
delete(multiProgress.Items, itemID)
+ markMultiProgressDirtyLocked()
}
func ClearAllItemProgress() {
@@ -174,6 +200,7 @@ func ClearAllItemProgress() {
defer multiMu.Unlock()
multiProgress.Items = make(map[string]*ItemProgress)
+ markMultiProgressDirtyLocked()
}
func setDownloadDir(path string) error {
diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go
index 092c0e96..cba711b0 100644
--- a/go_backend/qobuz.go
+++ b/go_backend/qobuz.go
@@ -28,21 +28,41 @@ type QobuzDownloader struct {
var (
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
+ qobuzGetTrackByIDFunc = func(q *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
+ return q.GetTrackByID(trackID)
+ }
+ qobuzSearchTrackByISRCWithDurationFunc = func(q *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
+ return q.SearchTrackByISRCWithDuration(isrc, expectedDurationSec)
+ }
+ qobuzSearchTrackByMetadataWithDurationFunc = func(q *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
+ return q.SearchTrackByMetadataWithDuration(trackName, artistName, expectedDurationSec)
+ }
+ songLinkCheckTrackAvailabilityFunc = func(client *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
+ return client.CheckTrackAvailability(spotifyTrackID, isrc)
+ }
)
const (
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id="
qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query="
+ qobuzAlbumGetBaseURL = "https://www.qobuz.com/api.json/0.2/album/get?album_id="
+ qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
+ qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
+ qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
- qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
+ qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
+ qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
+ qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
qobuzDebugKeyXORMask = byte(0x5A)
)
var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`)
+var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`)
+var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`)
var qobuzDebugKeyObfuscated = []byte{
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
@@ -58,23 +78,409 @@ type QobuzTrack struct {
ISRC string `json:"isrc"`
Duration int `json:"duration"`
TrackNumber int `json:"track_number"`
+ MediaNumber int `json:"media_number"`
MaximumBitDepth int `json:"maximum_bit_depth"`
MaximumSamplingRate float64 `json:"maximum_sampling_rate"`
+ Version string `json:"version"`
Album struct {
+ ID string `json:"id"`
+ QobuzID int64 `json:"qobuz_id"`
+ TracksCount int `json:"tracks_count"`
Title string `json:"title"`
ReleaseDate string `json:"release_date_original"`
- Image struct {
- Large string `json:"large"`
+ ProductType string `json:"product_type"`
+ ReleaseType string `json:"release_type"`
+ Artist struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ } `json:"artist"`
+ Artists []qobuzArtistRef `json:"artists"`
+ Image struct {
+ Thumbnail string `json:"thumbnail"`
+ Small string `json:"small"`
+ Large string `json:"large"`
} `json:"image"`
} `json:"album"`
Performer struct {
+ ID int64 `json:"id"`
Name string `json:"name"`
} `json:"performer"`
}
+type qobuzImageSet struct {
+ Thumbnail string `json:"thumbnail"`
+ Small string `json:"small"`
+ Large string `json:"large"`
+}
+
+type qobuzArtistRef struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+}
+
+type qobuzLabelRef struct {
+ Name string `json:"name"`
+}
+
+type qobuzGenreRef struct {
+ Name string `json:"name"`
+}
+
+type qobuzAlbumDetails struct {
+ ID string `json:"id"`
+ QobuzID int64 `json:"qobuz_id"`
+ Title string `json:"title"`
+ ReleaseDateOriginal string `json:"release_date_original"`
+ TracksCount int `json:"tracks_count"`
+ ProductType string `json:"product_type"`
+ ReleaseType string `json:"release_type"`
+ Image qobuzImageSet `json:"image"`
+ Artist qobuzArtistRef `json:"artist"`
+ Artists []qobuzArtistRef `json:"artists"`
+ Genre qobuzGenreRef `json:"genre"`
+ Label qobuzLabelRef `json:"label"`
+ Copyright string `json:"copyright"`
+ Tracks struct {
+ Items []QobuzTrack `json:"items"`
+ } `json:"tracks"`
+}
+
+type qobuzArtistDetails struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Image qobuzImageSet `json:"image"`
+}
+
+type qobuzPlaylistDetails struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ImageRectangle []string `json:"image_rectangle"`
+ ImageRectangleMini []string `json:"image_rectangle_mini"`
+ TracksCount int `json:"tracks_count"`
+ Owner struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ } `json:"owner"`
+ Tracks struct {
+ Total int `json:"total"`
+ Offset int `json:"offset"`
+ Limit int `json:"limit"`
+ Items []QobuzTrack `json:"items"`
+ } `json:"tracks"`
+}
+
+func qobuzFirstNonEmpty(values ...string) string {
+ for _, value := range values {
+ trimmed := strings.TrimSpace(value)
+ if trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
+
+func qobuzPrefixedID(id string) string {
+ trimmed := strings.TrimSpace(id)
+ if trimmed == "" {
+ return ""
+ }
+ if strings.HasPrefix(trimmed, "qobuz:") {
+ return trimmed
+ }
+ return "qobuz:" + trimmed
+}
+
+func qobuzPrefixedNumericID(id int64) string {
+ if id <= 0 {
+ return ""
+ }
+ return fmt.Sprintf("qobuz:%d", id)
+}
+
+func qobuzNormalizeReleaseDate(value string) string {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return ""
+ }
+ if _, err := time.Parse("2006-01-02", trimmed); err == nil {
+ return trimmed
+ }
+ if parsed, err := time.Parse("Jan 2, 2006", trimmed); err == nil {
+ return parsed.Format("2006-01-02")
+ }
+ return trimmed
+}
+
+func qobuzNormalizeAlbumType(releaseType, productType string, totalTracks int) string {
+ kind := strings.ToLower(strings.TrimSpace(releaseType))
+ if kind == "" {
+ kind = strings.ToLower(strings.TrimSpace(productType))
+ }
+ switch kind {
+ case "album", "single", "ep", "compilation":
+ return kind
+ }
+ if totalTracks > 0 && totalTracks <= 3 {
+ return "single"
+ }
+ return "album"
+}
+
+func qobuzArtistsDisplayName(artists []qobuzArtistRef, fallback string) string {
+ names := make([]string, 0, len(artists))
+ seen := make(map[string]struct{}, len(artists))
+ for _, artist := range artists {
+ name := strings.TrimSpace(artist.Name)
+ if name == "" {
+ continue
+ }
+ key := strings.ToLower(name)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ names = append(names, name)
+ }
+ if len(names) == 0 {
+ return strings.TrimSpace(fallback)
+ }
+ return strings.Join(names, ", ")
+}
+
+func qobuzTrackDisplayTitle(track *QobuzTrack) string {
+ if track == nil {
+ return ""
+ }
+ title := strings.TrimSpace(track.Title)
+ version := strings.TrimSpace(track.Version)
+ if title == "" || version == "" {
+ return title
+ }
+ return fmt.Sprintf("%s (%s)", title, version)
+}
+
+func qobuzTrackAlbumImage(track *QobuzTrack) string {
+ if track == nil {
+ return ""
+ }
+ return qobuzFirstNonEmpty(
+ track.Album.Image.Large,
+ track.Album.Image.Small,
+ track.Album.Image.Thumbnail,
+ )
+}
+
+func qobuzAlbumImage(album *qobuzAlbumDetails) string {
+ if album == nil {
+ return ""
+ }
+ return qobuzFirstNonEmpty(
+ album.Image.Large,
+ album.Image.Small,
+ album.Image.Thumbnail,
+ )
+}
+
+func qobuzTrackArtistID(track *QobuzTrack) string {
+ if track == nil {
+ return ""
+ }
+ if track.Performer.ID > 0 {
+ return qobuzPrefixedNumericID(track.Performer.ID)
+ }
+ return qobuzPrefixedNumericID(track.Album.Artist.ID)
+}
+
+func qobuzTrackArtistName(track *QobuzTrack) string {
+ if track == nil {
+ return ""
+ }
+ return strings.TrimSpace(track.Performer.Name)
+}
+
+func qobuzTrackAlbumArtist(track *QobuzTrack) string {
+ if track == nil {
+ return ""
+ }
+ return qobuzArtistsDisplayName(track.Album.Artists, track.Album.Artist.Name)
+}
+
+func qobuzTrackAlbumType(track *QobuzTrack) string {
+ if track == nil {
+ return "album"
+ }
+ return qobuzNormalizeAlbumType(
+ track.Album.ReleaseType,
+ track.Album.ProductType,
+ track.Album.TracksCount,
+ )
+}
+
+func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
+ if track == nil {
+ return TrackMetadata{}
+ }
+ return TrackMetadata{
+ SpotifyID: qobuzPrefixedNumericID(track.ID),
+ Artists: qobuzTrackArtistName(track),
+ Name: qobuzTrackDisplayTitle(track),
+ AlbumName: strings.TrimSpace(track.Album.Title),
+ AlbumArtist: qobuzTrackAlbumArtist(track),
+ DurationMS: track.Duration * 1000,
+ Images: qobuzTrackAlbumImage(track),
+ ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate),
+ TrackNumber: track.TrackNumber,
+ TotalTracks: track.Album.TracksCount,
+ DiscNumber: track.MediaNumber,
+ ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID),
+ ISRC: strings.TrimSpace(track.ISRC),
+ AlbumID: qobuzPrefixedID(track.Album.ID),
+ ArtistID: qobuzTrackArtistID(track),
+ AlbumType: qobuzTrackAlbumType(track),
+ }
+}
+
+func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
+ if track == nil {
+ return AlbumTrackMetadata{}
+ }
+ return AlbumTrackMetadata{
+ SpotifyID: qobuzPrefixedNumericID(track.ID),
+ Artists: qobuzTrackArtistName(track),
+ Name: qobuzTrackDisplayTitle(track),
+ AlbumName: strings.TrimSpace(track.Album.Title),
+ AlbumArtist: qobuzTrackAlbumArtist(track),
+ DurationMS: track.Duration * 1000,
+ Images: qobuzTrackAlbumImage(track),
+ ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate),
+ TrackNumber: track.TrackNumber,
+ TotalTracks: track.Album.TracksCount,
+ DiscNumber: track.MediaNumber,
+ ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID),
+ ISRC: strings.TrimSpace(track.ISRC),
+ AlbumID: qobuzPrefixedID(track.Album.ID),
+ AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
+ AlbumType: qobuzTrackAlbumType(track),
+ }
+}
+
+func qobuzAlbumToAlbumInfo(album *qobuzAlbumDetails) AlbumInfoMetadata {
+ if album == nil {
+ return AlbumInfoMetadata{}
+ }
+ return AlbumInfoMetadata{
+ TotalTracks: album.TracksCount,
+ Name: strings.TrimSpace(album.Title),
+ ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
+ Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
+ ArtistId: qobuzPrefixedNumericID(album.Artist.ID),
+ Images: qobuzAlbumImage(album),
+ Genre: strings.TrimSpace(album.Genre.Name),
+ Label: strings.TrimSpace(album.Label.Name),
+ Copyright: strings.TrimSpace(album.Copyright),
+ }
+}
+
+func qobuzAlbumToArtistAlbum(album *qobuzAlbumDetails) ArtistAlbumMetadata {
+ if album == nil {
+ return ArtistAlbumMetadata{}
+ }
+ return ArtistAlbumMetadata{
+ ID: qobuzPrefixedID(album.ID),
+ Name: strings.TrimSpace(album.Title),
+ ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
+ TotalTracks: album.TracksCount,
+ Images: qobuzAlbumImage(album),
+ AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
+ Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
+ }
+}
+
+func qobuzSplitPathSegments(path string) []string {
+ rawSegments := strings.Split(strings.TrimSpace(path), "/")
+ segments := make([]string, 0, len(rawSegments))
+ for _, segment := range rawSegments {
+ trimmed := strings.TrimSpace(segment)
+ if trimmed == "" {
+ continue
+ }
+ segments = append(segments, trimmed)
+ }
+ if len(segments) > 0 && qobuzLocaleSegmentRegex.MatchString(strings.ToLower(segments[0])) {
+ return segments[1:]
+ }
+ return segments
+}
+
+func qobuzResourceTypeFromSegment(segment string) string {
+ switch strings.ToLower(strings.TrimSpace(segment)) {
+ case "album":
+ return "album"
+ case "interpreter", "artist":
+ return "artist"
+ case "playlist", "playlists":
+ return "playlist"
+ case "track":
+ return "track"
+ default:
+ return ""
+ }
+}
+
+func parseQobuzURL(input string) (string, string, error) {
+ raw := strings.TrimSpace(input)
+ if raw == "" {
+ return "", "", fmt.Errorf("empty Qobuz URL")
+ }
+
+ if strings.HasPrefix(strings.ToLower(raw), "qobuzapp://") {
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return "", "", err
+ }
+ resourceType := qobuzResourceTypeFromSegment(parsed.Host)
+ resourceID := strings.Trim(strings.TrimSpace(parsed.Path), "/")
+ if resourceType == "" || resourceID == "" {
+ return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
+ }
+ return resourceType, resourceID, nil
+ }
+
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Host == "" {
+ if !strings.Contains(raw, "://") {
+ parsed, err = url.Parse("https://" + raw)
+ }
+ }
+ if err != nil || parsed == nil || parsed.Host == "" {
+ return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
+ }
+
+ host := strings.ToLower(parsed.Host)
+ if host != "qobuz.com" && host != "www.qobuz.com" && host != "play.qobuz.com" {
+ return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
+ }
+
+ segments := qobuzSplitPathSegments(parsed.Path)
+ if len(segments) < 2 {
+ return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
+ }
+
+ resourceType := qobuzResourceTypeFromSegment(segments[0])
+ resourceID := strings.TrimSpace(segments[len(segments)-1])
+ if resourceType == "" || resourceID == "" {
+ return "", "", fmt.Errorf("invalid or unsupported Qobuz URL")
+ }
+
+ return resourceType, resourceID, nil
+}
+
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
- normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
- normFound := strings.ToLower(strings.TrimSpace(foundArtist))
+ normExpected := normalizeLooseArtistName(expectedArtist)
+ normFound := normalizeLooseArtistName(foundArtist)
if normExpected == normFound {
return true
@@ -386,9 +792,239 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
return &track, nil
}
+func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error {
+ req, err := http.NewRequest("GET", requestURL, nil)
+ if err != nil {
+ return err
+ }
+
+ resp, err := DoRequestWithUserAgent(q.client, req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ return json.NewDecoder(resp.Body).Decode(target)
+}
+
+func (q *QobuzDownloader) getQobuzBody(requestURL string) ([]byte, error) {
+ req, err := http.NewRequest("GET", requestURL, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := DoRequestWithUserAgent(q.client, req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ return io.ReadAll(resp.Body)
+}
+
+func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string {
+ matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
+ if len(matches) == 0 {
+ return nil
+ }
+
+ albumIDs := make([]string, 0, len(matches))
+ seen := make(map[string]struct{}, len(matches))
+ for _, match := range matches {
+ if len(match) < 2 {
+ continue
+ }
+ albumID := strings.TrimSpace(string(match[1]))
+ if albumID == "" {
+ continue
+ }
+ if _, ok := seen[albumID]; ok {
+ continue
+ }
+ seen[albumID] = struct{}{}
+ albumIDs = append(albumIDs, albumID)
+ }
+ return albumIDs
+}
+
+func (q *QobuzDownloader) getAlbumDetails(albumID string) (*qobuzAlbumDetails, error) {
+ requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID)
+ var album qobuzAlbumDetails
+ if err := q.getQobuzJSON(requestURL, &album); err != nil {
+ return nil, err
+ }
+ return &album, nil
+}
+
+func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) {
+ requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID)
+ var artist qobuzArtistDetails
+ if err := q.getQobuzJSON(requestURL, &artist); err != nil {
+ return nil, err
+ }
+ return &artist, nil
+}
+
+func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) {
+ requestURL := fmt.Sprintf(
+ "%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s",
+ qobuzPlaylistGetBaseURL,
+ url.QueryEscape(strings.TrimSpace(playlistID)),
+ limit,
+ offset,
+ q.appID,
+ )
+ var playlist qobuzPlaylistDetails
+ if err := q.getQobuzJSON(requestURL, &playlist); err != nil {
+ return nil, err
+ }
+ return &playlist, nil
+}
+
+func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) {
+ artist, err := q.getArtistDetails(artistID)
+ if err != nil {
+ return nil, err
+ }
+
+ slug := strings.TrimSpace(artist.Slug)
+ if slug == "" {
+ slug = "artist"
+ }
+ requestURL := fmt.Sprintf("%s/interpreter/%s/%d", qobuzStoreBaseURL, url.PathEscape(slug), artist.ID)
+ body, err := q.getQobuzBody(requestURL)
+ if err != nil {
+ return nil, err
+ }
+
+ albumIDs := extractQobuzAlbumIDsFromArtistHTML(body)
+ if len(albumIDs) == 0 {
+ return nil, fmt.Errorf("artist page did not contain album IDs")
+ }
+ return albumIDs, nil
+}
+
+func (q *QobuzDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
+ trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64)
+ if err != nil || trackID <= 0 {
+ return nil, fmt.Errorf("invalid Qobuz track ID: %s", resourceID)
+ }
+
+ track, err := q.GetTrackByID(trackID)
+ if err != nil {
+ return nil, err
+ }
+
+ return &TrackResponse{Track: qobuzTrackToTrackMetadata(track)}, nil
+}
+
+func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) {
+ album, err := q.getAlbumDetails(resourceID)
+ if err != nil {
+ return nil, err
+ }
+
+ tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
+ for i := range album.Tracks.Items {
+ tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i]))
+ }
+
+ return &AlbumResponsePayload{
+ AlbumInfo: qobuzAlbumToAlbumInfo(album),
+ TrackList: tracks,
+ }, nil
+}
+
+func (q *QobuzDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) {
+ const pageSize = 50
+
+ offset := 0
+ var playlistInfo PlaylistInfoMetadata
+ tracks := make([]AlbumTrackMetadata, 0, pageSize)
+
+ for {
+ page, err := q.getPlaylistDetailsPage(resourceID, pageSize, offset)
+ if err != nil {
+ return nil, err
+ }
+
+ if offset == 0 {
+ total := page.Tracks.Total
+ if total == 0 {
+ total = page.TracksCount
+ }
+ playlistInfo.Tracks.Total = total
+ playlistInfo.Owner.DisplayName = strings.TrimSpace(page.Owner.Name)
+ playlistInfo.Owner.Name = strings.TrimSpace(page.Name)
+ playlistInfo.Owner.Images = qobuzFirstNonEmpty(page.ImageRectangle...)
+ }
+
+ for i := range page.Tracks.Items {
+ tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&page.Tracks.Items[i]))
+ }
+
+ if len(page.Tracks.Items) == 0 ||
+ offset+len(page.Tracks.Items) >= playlistInfo.Tracks.Total ||
+ len(page.Tracks.Items) < pageSize {
+ break
+ }
+ offset += len(page.Tracks.Items)
+ }
+
+ return &PlaylistResponsePayload{
+ PlaylistInfo: playlistInfo,
+ TrackList: tracks,
+ }, nil
+}
+
+func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) {
+ artist, err := q.getArtistDetails(resourceID)
+ if err != nil {
+ return nil, err
+ }
+
+ albumIDs, err := q.getArtistAlbumIDs(resourceID)
+ if err != nil {
+ return nil, err
+ }
+
+ albums := make([]ArtistAlbumMetadata, 0, len(albumIDs))
+ for _, albumID := range albumIDs {
+ album, albumErr := q.getAlbumDetails(albumID)
+ if albumErr != nil {
+ GoLog("[Qobuz] Skipping artist album %s: %v\n", albumID, albumErr)
+ continue
+ }
+ albums = append(albums, qobuzAlbumToArtistAlbum(album))
+ }
+
+ return &ArtistResponsePayload{
+ ArtistInfo: ArtistInfoMetadata{
+ ID: qobuzPrefixedNumericID(artist.ID),
+ Name: strings.TrimSpace(artist.Name),
+ Images: qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail),
+ },
+ Albums: albums,
+ }, nil
+}
+
func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{
qobuzDownloadAPIURL,
+ qobuzDabMusicAPIURL,
+ qobuzDeebAPIURL,
+ qobuzAfkarAPIURL,
+ qobuzSquidAPIURL,
}
}
@@ -409,6 +1045,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
+ // "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
+ {Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
}
}
@@ -648,6 +1286,155 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
}
+func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
+ cleanQuery := strings.TrimSpace(query)
+ if cleanQuery == "" {
+ return nil, fmt.Errorf("empty qobuz search query")
+ }
+ if limit <= 0 {
+ limit = 20
+ }
+
+ tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]ExtTrackMetadata, 0, len(tracks))
+ for i := range tracks {
+ results = append(results, normalizeBuiltInMetadataTrack(qobuzTrackToTrackMetadata(&tracks[i]), "qobuz"))
+ }
+ return results, nil
+}
+
+// SearchAll searches Qobuz for tracks, artists, and albums matching the query.
+// Returns results in the same SearchAllResult format as Deezer's SearchAll.
+func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
+ GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
+
+ cleanQuery := strings.TrimSpace(query)
+ if cleanQuery == "" {
+ return nil, fmt.Errorf("empty qobuz search query")
+ }
+
+ albumLimit := 5
+
+ if filter != "" {
+ switch filter {
+ case "track":
+ trackLimit = 50
+ artistLimit = 0
+ albumLimit = 0
+ case "artist":
+ trackLimit = 0
+ artistLimit = 20
+ albumLimit = 0
+ case "album":
+ trackLimit = 0
+ artistLimit = 0
+ albumLimit = 20
+ }
+ }
+
+ result := &SearchAllResult{
+ Tracks: make([]TrackMetadata, 0, trackLimit),
+ Artists: make([]SearchArtistResult, 0, artistLimit),
+ Albums: make([]SearchAlbumResult, 0, albumLimit),
+ Playlists: make([]SearchPlaylistResult, 0),
+ }
+
+ if trackLimit > 0 {
+ tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit)
+ if err != nil {
+ GoLog("[Qobuz] Track search failed: %v\n", err)
+ return nil, fmt.Errorf("qobuz track search failed: %w", err)
+ }
+ GoLog("[Qobuz] Got %d tracks from API\n", len(tracks))
+ for i := range tracks {
+ result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i]))
+ }
+ }
+
+ if artistLimit > 0 {
+ searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s",
+ url.QueryEscape(cleanQuery), artistLimit, q.appID)
+ req, err := http.NewRequest("GET", searchURL, nil)
+ if err == nil {
+ resp, reqErr := DoRequestWithUserAgent(q.client, req)
+ if reqErr == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode == 200 {
+ var artistResp struct {
+ Artists struct {
+ Items []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Image qobuzImageSet `json:"image"`
+ } `json:"items"`
+ } `json:"artists"`
+ }
+ if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil {
+ GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items))
+ for _, artist := range artistResp.Artists.Items {
+ imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail)
+ result.Artists = append(result.Artists, SearchArtistResult{
+ ID: qobuzPrefixedNumericID(artist.ID),
+ Name: strings.TrimSpace(artist.Name),
+ Images: imageURL,
+ })
+ }
+ } else {
+ GoLog("[Qobuz] Artist search decode failed: %v\n", decErr)
+ }
+ }
+ } else {
+ GoLog("[Qobuz] Artist search request failed: %v\n", reqErr)
+ }
+ }
+ }
+
+ if albumLimit > 0 {
+ searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
+ url.QueryEscape(cleanQuery), albumLimit, q.appID)
+ req, err := http.NewRequest("GET", searchURL, nil)
+ if err == nil {
+ resp, reqErr := DoRequestWithUserAgent(q.client, req)
+ if reqErr == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode == 200 {
+ var albumResp struct {
+ Albums struct {
+ Items []qobuzAlbumDetails `json:"items"`
+ } `json:"albums"`
+ }
+ if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil {
+ GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items))
+ for i := range albumResp.Albums.Items {
+ album := &albumResp.Albums.Items[i]
+ result.Albums = append(result.Albums, SearchAlbumResult{
+ ID: qobuzPrefixedID(album.ID),
+ Name: strings.TrimSpace(album.Title),
+ Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
+ Images: qobuzAlbumImage(album),
+ ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
+ TotalTracks: album.TracksCount,
+ AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
+ })
+ }
+ } else {
+ GoLog("[Qobuz] Album search decode failed: %v\n", decErr)
+ }
+ }
+ } else {
+ GoLog("[Qobuz] Album search request failed: %v\n", reqErr)
+ }
+ }
+ }
+
+ GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
+ return result, nil
+}
+
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
queries := []string{}
@@ -791,6 +1578,39 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
}
+func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool {
+ if track == nil {
+ return false
+ }
+
+ if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
+ GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
+ logPrefix, source, req.ArtistName, track.Performer.Name)
+ return false
+ }
+
+ if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
+ GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
+ logPrefix, source, req.TrackName, track.Title)
+ return false
+ }
+
+ expectedDurationSec := req.DurationMS / 1000
+ if expectedDurationSec > 0 && track.Duration > 0 {
+ durationDiff := track.Duration - expectedDurationSec
+ if durationDiff < 0 {
+ durationDiff = -durationDiff
+ }
+ if durationDiff > 10 {
+ GoLog("[%s] Duration mismatch from %s: expected %ds, got %ds. Rejecting.\n",
+ logPrefix, source, expectedDurationSec, track.Duration)
+ return false
+ }
+ }
+
+ return true
+}
+
func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) {
searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
@@ -940,19 +1760,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
}
+func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) {
+ requestQuality := mapQobuzQualityCodeToAPI(quality)
+ payload := map[string]any{
+ "quality": requestQuality,
+ "upload_to_r2": false,
+ "url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID),
+ }
+ return json.Marshal(payload)
+}
+
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
var lastErr error
retryDelay := qobuzRetryDelay
var payloadBytes []byte
if provider.Kind == qobuzAPIKindMusicDL {
- requestQuality := mapQobuzQualityCodeToAPI(quality)
- payload := map[string]any{
- "quality": requestQuality,
- "upload_to_r2": false,
- "url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
- }
var err error
- payloadBytes, err = json.Marshal(payload)
+ payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality)
if err != nil {
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
}
@@ -997,7 +1821,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
}
if provider.Kind == qobuzAPIKindMusicDL {
req.Header.Set("Content-Type", "application/json")
- req.Header.Set("X-Debug-Key", getQobuzDebugKey())
}
resp, err := DoRequestWithUserAgent(client, req)
@@ -1244,9 +2067,23 @@ type QobuzDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
+ CoverURL string
LyricsLRC string
}
+func parseQobuzRequestTrackID(raw string) int64 {
+ trimmed := strings.TrimSpace(raw)
+ trimmed = strings.TrimPrefix(trimmed, "qobuz:")
+ if trimmed == "" {
+ return 0
+ }
+ var trackID int64
+ if _, err := fmt.Sscanf(trimmed, "%d", &trackID); err != nil || trackID <= 0 {
+ return 0
+ }
+ return trackID
+}
+
func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) {
if downloader == nil {
downloader = NewQobuzDownloader()
@@ -1260,17 +2097,20 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
var track *QobuzTrack
var err error
- // Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
+ // Strategy 1: Use Qobuz ID from request payload (fastest, most accurate)
if req.QobuzID != "" {
- GoLog("[%s] Using Qobuz ID from Odesli enrichment: %s\n", logPrefix, req.QobuzID)
- var trackID int64
- if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
- track, err = downloader.GetTrackByID(trackID)
+ GoLog("[%s] Using Qobuz ID from request payload: %s\n", logPrefix, req.QobuzID)
+ if trackID := parseQobuzRequestTrackID(req.QobuzID); trackID > 0 {
+ track, err = qobuzGetTrackByIDFunc(downloader, trackID)
if err != nil {
- GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err)
+ GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
- GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
+ if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") {
+ GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
+ } else {
+ track = nil
+ }
}
}
}
@@ -1279,10 +2119,12 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID)
- track, err = downloader.GetTrackByID(cached.QobuzTrackID)
+ track, err = qobuzGetTrackByIDFunc(downloader, cached.QobuzTrackID)
if err != nil {
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
track = nil
+ } else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") {
+ track = nil
}
}
}
@@ -1291,19 +2133,23 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID)
songLinkClient := NewSongLinkClient()
- availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
+ availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
if slErr == nil && availability != nil && availability.QobuzID != "" {
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID)
- track, err = downloader.GetTrackByID(trackID)
+ track, err = qobuzGetTrackByIDFunc(downloader, trackID)
if err != nil {
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
- GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
- if req.ISRC != "" {
- GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
+ if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") {
+ GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
+ if req.ISRC != "" {
+ GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
+ }
+ } else {
+ track = nil
}
}
}
@@ -1313,27 +2159,17 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
// Strategy 4: ISRC search with duration verification
if track == nil && req.ISRC != "" {
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
- track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
- if track != nil {
- if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
- GoLog("[%s] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
- logPrefix, req.ArtistName, track.Performer.Name)
- track = nil
- } else if !qobuzTitlesMatch(req.TrackName, track.Title) {
- GoLog("[%s] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
- logPrefix, req.TrackName, track.Title)
- track = nil
- }
+ track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
+ if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") {
+ track = nil
}
}
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
- track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
- if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
- GoLog("[%s] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
- logPrefix, req.ArtistName, track.Performer.Name)
+ track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
+ if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") {
track = nil
}
}
@@ -1425,7 +2261,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
- coverURL := req.CoverURL
+ coverURL := strings.TrimSpace(req.CoverURL)
+ if coverURL == "" {
+ coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
+ }
embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata {
coverURL = ""
@@ -1460,6 +2299,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
if req.AlbumName != "" {
albumName = req.AlbumName
}
+ releaseDate := track.Album.ReleaseDate
+ if req.ReleaseDate != "" {
+ releaseDate = req.ReleaseDate
+ }
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
@@ -1471,7 +2314,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist,
- Date: track.Album.ReleaseDate,
+ Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
@@ -1535,17 +2378,26 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
lyricsLRC = parallelResult.LyricsLRC
}
+ resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
+ req,
+ track.Album.Title,
+ track.Album.ReleaseDate,
+ actualTrackNumber,
+ req.DiscNumber,
+ )
+
return QobuzDownloadResult{
FilePath: outputPath,
BitDepth: actualBitDepth,
SampleRate: actualSampleRate,
Title: track.Title,
Artist: track.Performer.Name,
- Album: track.Album.Title,
- ReleaseDate: track.Album.ReleaseDate,
- TrackNumber: actualTrackNumber,
- DiscNumber: req.DiscNumber,
+ Album: resultAlbum,
+ ReleaseDate: resultReleaseDate,
+ TrackNumber: resultTrackNumber,
+ DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
+ CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
LyricsLRC: lyricsLRC,
}, nil
}
diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go
index 0382b62f..df3b84ac 100644
--- a/go_backend/qobuz_test.go
+++ b/go_backend/qobuz_test.go
@@ -1,6 +1,98 @@
package gobackend
-import "testing"
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestParseQobuzURL(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantType string
+ wantID string
+ expectErr bool
+ }{
+ {
+ name: "store album url",
+ input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985",
+ wantType: "album",
+ wantID: "0886446451985",
+ },
+ {
+ name: "store playlist url",
+ input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430",
+ wantType: "playlist",
+ wantID: "2049430",
+ },
+ {
+ name: "store artist url",
+ input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886",
+ wantType: "artist",
+ wantID: "729886",
+ },
+ {
+ name: "play track url",
+ input: "https://play.qobuz.com/track/40681594",
+ wantType: "track",
+ wantID: "40681594",
+ },
+ {
+ name: "custom scheme playlist url",
+ input: "qobuzapp://playlist/2049430",
+ wantType: "playlist",
+ wantID: "2049430",
+ },
+ {
+ name: "unsupported url",
+ input: "https://example.com/not-qobuz",
+ expectErr: true,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ gotType, gotID, err := parseQobuzURL(test.input)
+ if test.expectErr {
+ if err == nil {
+ t.Fatalf("expected error, got none")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if gotType != test.wantType || gotID != test.wantID {
+ t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
+ }
+ })
+ }
+}
+
+func TestExtractQobuzArtistAlbumIDs(t *testing.T) {
+ body := []byte(`
+
+
+
+
+
+
+
+
+
+`)
+
+ matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1)
+ if len(matches) != 3 {
+ t.Fatalf("expected 3 regex matches, got %d", len(matches))
+ }
+ if string(matches[0][1]) != "yrpbt0lwm3g0y" {
+ t.Fatalf("unexpected first album id: %q", matches[0][1])
+ }
+ if string(matches[2][1]) != "0886446451985" {
+ t.Fatalf("unexpected last album id: %q", matches[2][1])
+ }
+}
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
t.Run("reads top-level download_url and quality metadata", func(t *testing.T) {
@@ -106,16 +198,56 @@ func TestGetQobuzDebugKey(t *testing.T) {
}
}
+func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
+ payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
+ if err != nil {
+ t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(payloadBytes, &payload); err != nil {
+ t.Fatalf("payload is not valid JSON: %v", err)
+ }
+
+ if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
+ t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
+ }
+ if got := payload["quality"]; got != "hi-res" {
+ t.Fatalf("payload quality = %v, want hi-res", got)
+ }
+ if got := payload["upload_to_r2"]; got != false {
+ t.Fatalf("payload upload_to_r2 = %v, want false", got)
+ }
+}
+
+func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
+ body := []byte(`
+
+
+
+ `)
+
+ got := extractQobuzAlbumIDsFromArtistHTML(body)
+ if len(got) != 2 {
+ t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got)
+ }
+ if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" {
+ t.Fatalf("unexpected album IDs: %v", got)
+ }
+}
+
func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders()
- if len(providers) != 3 {
- t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
+ if len(providers) != 5 {
+ t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
}
want := map[string]string{
"musicdl": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard,
+ "qbz": qobuzAPIKindStandard,
+ "squid": qobuzAPIKindStandard,
}
for _, provider := range providers {
@@ -133,3 +265,174 @@ func TestQobuzAvailableProviders(t *testing.T) {
t.Fatalf("missing providers: %v", want)
}
}
+
+func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
+ track := &QobuzTrack{
+ ID: id,
+ Title: title,
+ Duration: duration,
+ }
+ track.Performer.Name = artist
+ return track
+}
+
+func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
+ origGetTrackByID := qobuzGetTrackByIDFunc
+ origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
+ origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
+ origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
+ t.Cleanup(func() {
+ qobuzGetTrackByIDFunc = origGetTrackByID
+ qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
+ qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
+ songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
+ GetTrackIDCache().Clear()
+ })
+ GetTrackIDCache().Clear()
+
+ qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
+ if trackID != 111 {
+ t.Fatalf("unexpected track ID lookup: %d", trackID)
+ }
+ return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil
+ }
+ qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
+ if isrc != "TESTISRC1" {
+ t.Fatalf("unexpected ISRC lookup: %q", isrc)
+ }
+ if expectedDurationSec != 180 {
+ t.Fatalf("unexpected duration: %d", expectedDurationSec)
+ }
+ return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil
+ }
+ qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
+ t.Fatal("metadata fallback should not run when ISRC fallback succeeds")
+ return nil, nil
+ }
+ songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
+ if spotifyTrackID != "spotify-track-id" {
+ t.Fatalf("unexpected spotify ID: %q", spotifyTrackID)
+ }
+ if isrc != "TESTISRC1" {
+ t.Fatalf("unexpected SongLink ISRC: %q", isrc)
+ }
+ return &TrackAvailability{QobuzID: "111"}, nil
+ }
+
+ req := DownloadRequest{
+ ISRC: "TESTISRC1",
+ SpotifyID: "spotify-track-id",
+ TrackName: "Taste Back",
+ ArtistName: "Harry Styles",
+ DurationMS: 180000,
+ }
+
+ track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if track == nil || track.ID != 222 || track.Title != "Taste Back" {
+ t.Fatalf("unexpected resolved track: %+v", track)
+ }
+
+ cached := GetTrackIDCache().Get(req.ISRC)
+ if cached == nil || cached.QobuzTrackID != 222 {
+ t.Fatalf("expected validated fallback track to be cached, got %+v", cached)
+ }
+}
+
+func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
+ origGetTrackByID := qobuzGetTrackByIDFunc
+ origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
+ origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
+ origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
+ t.Cleanup(func() {
+ qobuzGetTrackByIDFunc = origGetTrackByID
+ qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
+ qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
+ songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
+ })
+
+ qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
+ if trackID != 333 {
+ t.Fatalf("unexpected track ID lookup: %d", trackID)
+ }
+ return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil
+ }
+ qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
+ t.Fatal("ISRC fallback should not run without an ISRC")
+ return nil, nil
+ }
+ qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
+ if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
+ t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
+ }
+ return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
+ }
+ songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
+ t.Fatal("SongLink should not run when Odesli QobuzID is provided")
+ return nil, nil
+ }
+
+ req := DownloadRequest{
+ QobuzID: "333",
+ TrackName: "Taste Back",
+ ArtistName: "Harry Styles",
+ DurationMS: 181000,
+ }
+
+ track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if track == nil || track.ID != 444 || track.Title != "Taste Back" {
+ t.Fatalf("unexpected resolved track: %+v", track)
+ }
+}
+
+func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) {
+ origGetTrackByID := qobuzGetTrackByIDFunc
+ origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
+ origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
+ origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
+ t.Cleanup(func() {
+ qobuzGetTrackByIDFunc = origGetTrackByID
+ qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
+ qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
+ songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
+ })
+
+ qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
+ if trackID != 40681594 {
+ t.Fatalf("unexpected track ID lookup: %d", trackID)
+ }
+ return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil
+ }
+ qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
+ t.Fatal("ISRC fallback should not run when request qobuz id succeeds")
+ return nil, nil
+ }
+ qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
+ t.Fatal("metadata fallback should not run when request qobuz id succeeds")
+ return nil, nil
+ }
+ songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
+ t.Fatal("SongLink should not run when request qobuz id is provided")
+ return nil, nil
+ }
+
+ req := DownloadRequest{
+ QobuzID: "qobuz:40681594",
+ TrackName: "Sign of the Times",
+ ArtistName: "Harry Styles",
+ DurationMS: 341000,
+ }
+
+ track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if track == nil || track.ID != 40681594 {
+ t.Fatalf("unexpected resolved track: %+v", track)
+ }
+}
diff --git a/go_backend/security_hardening_test.go b/go_backend/security_hardening_test.go
deleted file mode 100644
index 559ccbd8..00000000
--- a/go_backend/security_hardening_test.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package gobackend
-
-import (
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestSanitizeSensitiveLogText(t *testing.T) {
- input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
- redacted := sanitizeSensitiveLogText(input)
-
- if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
- t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
- }
- if !strings.Contains(redacted, "[REDACTED]") {
- t.Fatalf("expected redaction marker in output, got: %s", redacted)
- }
-}
-
-func TestValidateExtensionAuthURL(t *testing.T) {
- if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
- t.Fatalf("expected valid auth URL, got error: %v", err)
- }
-
- blocked := []string{
- "http://accounts.example.com/oauth/authorize",
- "https://user:pass@accounts.example.com/oauth/authorize",
- "https://localhost/oauth/authorize",
- }
-
- for _, rawURL := range blocked {
- if err := validateExtensionAuthURL(rawURL); err == nil {
- t.Fatalf("expected URL to be blocked: %s", rawURL)
- }
- }
-}
-
-func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
- ext := &LoadedExtension{
- ID: "test-ext",
- Manifest: &ExtensionManifest{
- Name: "test-ext",
- Permissions: ExtensionPermissions{
- Network: []string{"api.example.com"},
- },
- },
- DataDir: t.TempDir(),
- }
-
- runtime := NewExtensionRuntime(ext)
- if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
- t.Fatal("expected embedded URL credentials to be rejected")
- }
-}
-
-func TestBuildStoreExtensionDestPath(t *testing.T) {
- baseDir := t.TempDir()
-
- destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
- if err != nil {
- t.Fatalf("expected sanitized path to be generated, got error: %v", err)
- }
-
- if !isPathWithinBase(baseDir, destPath) {
- t.Fatalf("expected destination path to remain under base dir: %s", destPath)
- }
-
- baseName := filepath.Base(destPath)
- if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
- t.Fatalf("expected filename to be sanitized, got: %s", baseName)
- }
- if !strings.HasSuffix(baseName, ".spotiflac-ext") {
- t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
- }
-
- if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
- t.Fatal("expected empty extension id to be rejected")
- }
-}
diff --git a/go_backend/songlink.go b/go_backend/songlink.go
index f38a8edb..2a6515b8 100644
--- a/go_backend/songlink.go
+++ b/go_backend/songlink.go
@@ -1,6 +1,7 @@
package gobackend
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -14,6 +15,10 @@ type SongLinkClient struct {
client *http.Client
}
+type songLinkPlatformLink struct {
+ URL string `json:"url"`
+}
+
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
@@ -43,6 +48,7 @@ var (
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
return s.CheckAvailabilityFromDeezer(deezerTrackID)
}
+ songLinkRetryConfig = DefaultRetryConfig
)
func NewSongLinkClient() *SongLinkClient {
@@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
- songLinkRateLimiter.WaitForSlot()
+ availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
+ if pageErr == nil {
+ return availability, nil
+ }
+
+ if !songLinkRateLimiter.TryAcquire() {
+ return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
+ }
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
@@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
return nil, fmt.Errorf("failed to create request: %w", err)
}
- retryConfig := DefaultRetryConfig()
+ retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
- return nil, fmt.Errorf("failed to check availability: %w", err)
+ return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
}
defer resp.Body.Close()
@@ -154,10 +167,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
- return nil, fmt.Errorf("SongLink rate limit exceeded")
+ return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
}
if resp.StatusCode != 200 {
- return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
+ return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
}
body, err := ReadResponseBody(resp)
@@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
}
var songLinkResp struct {
- LinksByPlatform map[string]struct {
- URL string `json:"url"`
- } `json:"linksByPlatform"`
+ LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
- availability := &TrackAvailability{
- SpotifyID: spotifyTrackID,
+ LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
+ return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
+}
+
+func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
+ pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
+ req, err := http.NewRequest("GET", pageURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create song.link page request: %w", err)
}
- if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
- availability.Tidal = true
- availability.TidalURL = tidalLink.URL
- availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
+ req.Header.Set("Accept", "text/html,application/xhtml+xml")
+ req.Header.Set("User-Agent", getRandomUserAgent())
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 404 {
+ return nil, fmt.Errorf("track not found on song.link page")
+ }
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
}
- if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
- availability.Amazon = true
- availability.AmazonURL = amazonLink.URL
+ body, err := ReadResponseBody(resp)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read song.link page: %w", err)
}
- if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
- availability.Deezer = true
- availability.DeezerURL = deezerLink.URL
- availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
+ nextDataJSON, err := extractSongLinkNextDataJSON(body)
+ if err != nil {
+ return nil, err
}
- if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
- availability.Qobuz = true
- availability.QobuzURL = qobuzLink.URL
- availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
+ var pageData struct {
+ Props struct {
+ PageProps struct {
+ PageData struct {
+ Sections []struct {
+ Links []struct {
+ Platform string `json:"platform"`
+ URL string `json:"url"`
+ Show bool `json:"show"`
+ } `json:"links"`
+ } `json:"sections"`
+ } `json:"pageData"`
+ } `json:"pageProps"`
+ } `json:"props"`
+ }
+ if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
+ return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
}
- // Prefer youtubeMusic URLs — they bypass Cobalt login requirements
- if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
- availability.YouTube = true
- availability.YouTubeURL = ytMusicLink.URL
- availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
- }
-
- // Fallback to regular youtube if youtubeMusic not available
- if !availability.YouTube {
- if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
- availability.YouTube = true
- availability.YouTubeURL = youtubeLink.URL
- availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
+ linksByPlatform := make(map[string]songLinkPlatformLink)
+ for _, section := range pageData.Props.PageProps.PageData.Sections {
+ for _, link := range section.Links {
+ if !link.Show || strings.TrimSpace(link.URL) == "" {
+ continue
+ }
+ linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
}
}
- return availability, nil
+ if len(linksByPlatform) == 0 {
+ return nil, fmt.Errorf("song.link page contained no usable platform links")
+ }
+
+ return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
+}
+
+func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
+ const startMarker = ``
+
+ start := bytes.Index(body, []byte(startMarker))
+ if start < 0 {
+ return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
+ }
+ start += len(startMarker)
+
+ end := bytes.Index(body[start:], []byte(endMarker))
+ if end < 0 {
+ return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
+ }
+
+ return body[start : start+end], nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
@@ -459,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
return nil, fmt.Errorf("failed to create request: %w", err)
}
- retryConfig := DefaultRetryConfig()
+ retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check album availability: %w", err)
@@ -542,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
return nil, fmt.Errorf("failed to create request: %w", err)
}
- retryConfig := DefaultRetryConfig()
+ retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
@@ -647,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return nil, fmt.Errorf("failed to create request: %w", err)
}
- retryConfig := DefaultRetryConfig()
+ retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
@@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return availability, nil
}
+func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
+ availability := &TrackAvailability{
+ SpotifyID: spotifyTrackID,
+ }
+
+ if availability.SpotifyID == "" {
+ if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
+ availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
+ }
+ }
+ if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
+ availability.Tidal = true
+ availability.TidalURL = tidalLink.URL
+ availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
+ }
+ if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
+ availability.Amazon = true
+ availability.AmazonURL = amazonLink.URL
+ }
+ if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
+ availability.Qobuz = true
+ availability.QobuzURL = qobuzLink.URL
+ availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
+ }
+ if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
+ availability.Deezer = true
+ availability.DeezerURL = deezerLink.URL
+ availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
+ }
+ if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
+ availability.YouTube = true
+ availability.YouTubeURL = ytMusicLink.URL
+ availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
+ }
+ if !availability.YouTube {
+ if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
+ availability.YouTube = true
+ availability.YouTubeURL = youtubeLink.URL
+ availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
+ }
+ }
+
+ return availability
+}
+
func extractSpotifyIDFromURL(spotifyURL string) string {
parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 {
@@ -802,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
return nil, fmt.Errorf("failed to create request: %w", err)
}
- retryConfig := DefaultRetryConfig()
+ retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
diff --git a/go_backend/songlink_test.go b/go_backend/songlink_test.go
new file mode 100644
index 00000000..ac085c1b
--- /dev/null
+++ b/go_backend/songlink_test.go
@@ -0,0 +1,127 @@
+package gobackend
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return fn(req)
+}
+
+func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
+ resp := &http.Response{
+ Header: make(http.Header),
+ }
+
+ if got := getRetryAfterDuration(resp); got != 0 {
+ t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
+ }
+}
+
+func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
+ client := &SongLinkClient{
+ client: &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ switch {
+ case req.URL.Host == "api.song.link":
+ t.Fatalf("api.song.link should not be called when song.link page succeeds")
+ return nil, nil
+ case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
+ body := ``
+ return &http.Response{
+ StatusCode: 200,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(body)),
+ Request: req,
+ }, nil
+ default:
+ t.Fatalf("unexpected request: %s", req.URL.String())
+ return nil, nil
+ }
+ }),
+ },
+ }
+
+ availability, err := client.CheckTrackAvailability("testspotifyid", "")
+ if err != nil {
+ t.Fatalf("CheckTrackAvailability() error = %v", err)
+ }
+
+ if availability.SpotifyID != "testspotifyid" {
+ t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
+ }
+ if !availability.Deezer || availability.DeezerID != "908604612" {
+ t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
+ }
+ if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
+ t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
+ }
+ if availability.YouTubeID != "testvideoid1" {
+ t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
+ }
+}
+
+func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
+ origRetryConfig := songLinkRetryConfig
+ songLinkRetryConfig = func() RetryConfig {
+ return RetryConfig{
+ MaxRetries: 0,
+ InitialDelay: 0,
+ MaxDelay: 0,
+ BackoffFactor: 1,
+ }
+ }
+ defer func() {
+ songLinkRetryConfig = origRetryConfig
+ }()
+
+ client := &SongLinkClient{
+ client: &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ switch {
+ case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
+ return &http.Response{
+ StatusCode: 500,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("page failure")),
+ Request: req,
+ }, nil
+ case req.URL.Host == "api.song.link":
+ body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
+ return &http.Response{
+ StatusCode: 200,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(body)),
+ Request: req,
+ }, nil
+ default:
+ t.Fatalf("unexpected request: %s", req.URL.String())
+ return nil, nil
+ }
+ }),
+ },
+ }
+
+ availability, err := client.CheckTrackAvailability("testspotifyid", "")
+ if err != nil {
+ t.Fatalf("CheckTrackAvailability() error = %v", err)
+ }
+
+ if availability.SpotifyID != "testspotifyid" {
+ t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
+ }
+ if !availability.Deezer || availability.DeezerID != "908604612" {
+ t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
+ }
+ if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
+ t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
+ }
+ if availability.YouTubeID != "testvideoid1" {
+ t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
+ }
+}
diff --git a/go_backend/spotfetch_api.go b/go_backend/spotfetch_api.go
index d6bfab19..e12804a4 100644
--- a/go_backend/spotfetch_api.go
+++ b/go_backend/spotfetch_api.go
@@ -10,7 +10,7 @@ import (
"time"
)
-const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
+const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
// This is used as a fallback when direct Spotify API access is blocked/limited.
diff --git a/go_backend/spotify.go b/go_backend/spotify.go
index 9728a72f..0514c5d4 100644
--- a/go_backend/spotify.go
+++ b/go_backend/spotify.go
@@ -9,7 +9,6 @@ import (
"math/rand"
"net/http"
"net/url"
- "os"
"strings"
"sync"
"time"
@@ -64,45 +63,20 @@ var (
credentialsMu sync.RWMutex
)
-var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
+var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead")
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
- customClientID = clientID
- customClientSecret = clientSecret
+ customClientID = ""
+ customClientSecret = ""
}
func HasSpotifyCredentials() bool {
- credentialsMu.RLock()
- defer credentialsMu.RUnlock()
-
- if customClientID != "" && customClientSecret != "" {
- return true
- }
-
- if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
- return true
- }
-
return false
}
func getCredentials() (string, string, error) {
- credentialsMu.RLock()
- defer credentialsMu.RUnlock()
-
- if customClientID != "" && customClientSecret != "" {
- return customClientID, customClientSecret, nil
- }
-
- clientID := os.Getenv("SPOTIFY_CLIENT_ID")
- clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
-
- if clientID != "" && clientSecret != "" {
- return clientID, clientSecret, nil
- }
-
return "", "", ErrNoSpotifyCredentials
}
@@ -183,6 +157,8 @@ type AlbumResponsePayload struct {
}
type PlaylistInfoMetadata struct {
+ Name string `json:"name,omitempty"`
+ Images string `json:"images,omitempty"`
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
diff --git a/go_backend/tidal.go b/go_backend/tidal.go
index ea9a8886..34de563e 100644
--- a/go_backend/tidal.go
+++ b/go_backend/tidal.go
@@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"regexp"
+ "strconv"
"strings"
"sync"
"time"
@@ -25,13 +26,25 @@ type TidalDownloader struct {
}
var (
- globalTidalDownloader *TidalDownloader
- tidalDownloaderOnce sync.Once
+ globalTidalDownloader *TidalDownloader
+ tidalDownloaderOnce sync.Once
+ tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
+ return t.getTrackSearchPage(query, limit)
+ }
+ tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
+ return t.getPublicTrack(resourceID)
+ }
)
const (
spotifyTrackBaseURL = "https://open.spotify.com/track/"
songLinkLookupBaseURL = "https://api.song.link/v1-alpha.1/links?url="
+ tidalPublicAPIBaseURL = "https://tidal.com/v1"
+ tidalPublicToken = "txNoH4kkV41MfH25"
+ tidalResourceBaseURL = "https://resources.tidal.com"
+ tidalCountryCode = "US"
+ tidalLocale = "en_US"
+ tidalDeviceType = "BROWSER"
)
type TidalTrack struct {
@@ -43,19 +56,28 @@ type TidalTrack struct {
VolumeNumber int `json:"volumeNumber"`
Duration int `json:"duration"`
Album struct {
+ ID int64 `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
ReleaseDate string `json:"releaseDate"`
+ URL string `json:"url"`
} `json:"album"`
Artists []struct {
- Name string `json:"name"`
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Picture string `json:"picture"`
} `json:"artists"`
Artist struct {
- Name string `json:"name"`
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Picture string `json:"picture"`
} `json:"artist"`
MediaMetadata struct {
Tags []string `json:"tags"`
} `json:"mediaMetadata"`
+ URL string `json:"url"`
}
type TidalAPIResponseV2 struct {
@@ -100,6 +122,105 @@ type MPD struct {
} `xml:"Period"`
}
+type tidalPublicArtist struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Picture string `json:"picture"`
+}
+
+type tidalPublicAlbum struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Type string `json:"type"`
+ Cover string `json:"cover"`
+ ReleaseDate string `json:"releaseDate"`
+ URL string `json:"url"`
+ NumberOfTracks int `json:"numberOfTracks"`
+ Explicit bool `json:"explicit"`
+ Artists []tidalPublicArtist `json:"artists"`
+}
+
+type tidalPublicAlbumPage struct {
+ Rows []struct {
+ Modules []struct {
+ Type string `json:"type"`
+ Album tidalPublicAlbum `json:"album"`
+ PagedList struct {
+ DataAPIPath string `json:"dataApiPath"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ TotalNumberOfItems int `json:"totalNumberOfItems"`
+ Items []struct {
+ Item TidalTrack `json:"item"`
+ Type string `json:"type"`
+ } `json:"items"`
+ } `json:"pagedList"`
+ } `json:"modules"`
+ } `json:"rows"`
+}
+
+type tidalPublicArtistPage struct {
+ Rows []struct {
+ Modules []struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Artist struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Picture string `json:"picture"`
+ } `json:"artist"`
+ PagedList struct {
+ DataAPIPath string `json:"dataApiPath"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ TotalNumberOfItems int `json:"totalNumberOfItems"`
+ Items []tidalPublicAlbum `json:"items"`
+ } `json:"pagedList"`
+ } `json:"modules"`
+ } `json:"rows"`
+}
+
+type tidalPublicArtistAlbumsPage struct {
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ TotalNumberOfItems int `json:"totalNumberOfItems"`
+ Items []tidalPublicAlbum `json:"items"`
+}
+
+type tidalPublicPlaylist struct {
+ UUID string `json:"uuid"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Image string `json:"image"`
+ SquareImage string `json:"squareImage"`
+ NumberOfTracks int `json:"numberOfTracks"`
+ Creator struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ } `json:"creator"`
+}
+
+type tidalPublicPlaylistItemsPage struct {
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ TotalNumberOfItems int `json:"totalNumberOfItems"`
+ Items []struct {
+ Item TidalTrack `json:"item"`
+ Type string `json:"type"`
+ } `json:"items"`
+}
+
+type tidalPublicTrackSearchResponse struct {
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ TotalNumberOfItems int `json:"totalNumberOfItems"`
+ Items []TidalTrack `json:"items"`
+}
+
func NewTidalDownloader() *TidalDownloader {
tidalDownloaderOnce.Do(func() {
globalTidalDownloader = &TidalDownloader{
@@ -114,6 +235,457 @@ func NewTidalDownloader() *TidalDownloader {
return globalTidalDownloader
}
+func tidalPrefixedID(id string) string {
+ trimmed := strings.TrimSpace(id)
+ if trimmed == "" {
+ return ""
+ }
+ return "tidal:" + trimmed
+}
+
+func tidalPrefixedNumericID(id int64) string {
+ if id <= 0 {
+ return ""
+ }
+ return fmt.Sprintf("tidal:%d", id)
+}
+
+func tidalImageURL(imageID, size string) string {
+ normalizedID := strings.TrimSpace(imageID)
+ if normalizedID == "" || strings.TrimSpace(size) == "" {
+ return ""
+ }
+ return fmt.Sprintf(
+ "%s/images/%s/%s.jpg",
+ tidalResourceBaseURL,
+ strings.ReplaceAll(normalizedID, "-", "/"),
+ size,
+ )
+}
+
+func tidalFirstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
+
+func tidalJoinArtistNames(artists []tidalPublicArtist) string {
+ if len(artists) == 0 {
+ return ""
+ }
+
+ names := make([]string, 0, len(artists))
+ for _, artist := range artists {
+ if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
+ names = append(names, trimmed)
+ }
+ }
+ return strings.Join(names, ", ")
+}
+
+func tidalTrackArtistsDisplay(track *TidalTrack) string {
+ if track == nil {
+ return ""
+ }
+
+ if len(track.Artists) > 0 {
+ names := make([]string, 0, len(track.Artists))
+ for _, artist := range track.Artists {
+ if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
+ names = append(names, trimmed)
+ }
+ }
+ if len(names) > 0 {
+ return strings.Join(names, ", ")
+ }
+ }
+
+ return strings.TrimSpace(track.Artist.Name)
+}
+
+func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string {
+ if album == nil {
+ return ""
+ }
+ return tidalJoinArtistNames(album.Artists)
+}
+
+func tidalTrackExternalURL(track *TidalTrack) string {
+ if track == nil {
+ return ""
+ }
+ if trimmed := strings.TrimSpace(track.URL); trimmed != "" {
+ return strings.Replace(trimmed, "http://", "https://", 1)
+ }
+ if track.ID > 0 {
+ return fmt.Sprintf("https://tidal.com/browse/track/%d", track.ID)
+ }
+ return ""
+}
+
+func tidalAlbumExternalURL(album *tidalPublicAlbum) string {
+ if album == nil {
+ return ""
+ }
+ if trimmed := strings.TrimSpace(album.URL); trimmed != "" {
+ return strings.Replace(trimmed, "http://", "https://", 1)
+ }
+ if album.ID > 0 {
+ return fmt.Sprintf("https://tidal.com/browse/album/%d", album.ID)
+ }
+ return ""
+}
+
+func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata {
+ if track == nil {
+ return TrackMetadata{}
+ }
+
+ artistID := tidalPrefixedNumericID(track.Artist.ID)
+ if artistID == "" && len(track.Artists) > 0 {
+ artistID = tidalPrefixedNumericID(track.Artists[0].ID)
+ }
+
+ return TrackMetadata{
+ SpotifyID: tidalPrefixedNumericID(track.ID),
+ Artists: tidalTrackArtistsDisplay(track),
+ Name: strings.TrimSpace(track.Title),
+ AlbumName: strings.TrimSpace(track.Album.Title),
+ AlbumArtist: strings.TrimSpace(track.Artist.Name),
+ DurationMS: track.Duration * 1000,
+ Images: tidalImageURL(track.Album.Cover, "1280x1280"),
+ ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
+ TrackNumber: track.TrackNumber,
+ DiscNumber: track.VolumeNumber,
+ ExternalURL: tidalTrackExternalURL(track),
+ ISRC: strings.TrimSpace(track.ISRC),
+ AlbumID: tidalPrefixedNumericID(track.Album.ID),
+ ArtistID: artistID,
+ }
+}
+
+func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata {
+ if track == nil {
+ return AlbumTrackMetadata{}
+ }
+
+ return AlbumTrackMetadata{
+ SpotifyID: tidalPrefixedNumericID(track.ID),
+ Artists: tidalTrackArtistsDisplay(track),
+ Name: strings.TrimSpace(track.Title),
+ AlbumName: strings.TrimSpace(track.Album.Title),
+ AlbumArtist: strings.TrimSpace(track.Artist.Name),
+ DurationMS: track.Duration * 1000,
+ Images: tidalImageURL(track.Album.Cover, "1280x1280"),
+ ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
+ TrackNumber: track.TrackNumber,
+ DiscNumber: track.VolumeNumber,
+ ExternalURL: tidalTrackExternalURL(track),
+ ISRC: strings.TrimSpace(track.ISRC),
+ AlbumID: tidalPrefixedNumericID(track.Album.ID),
+ AlbumURL: strings.Replace(strings.TrimSpace(track.Album.URL), "http://", "https://", 1),
+ }
+}
+
+func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata {
+ if album == nil {
+ return AlbumInfoMetadata{}
+ }
+
+ artistID := ""
+ if len(album.Artists) > 0 {
+ artistID = tidalPrefixedNumericID(album.Artists[0].ID)
+ }
+
+ return AlbumInfoMetadata{
+ TotalTracks: album.NumberOfTracks,
+ Name: strings.TrimSpace(album.Title),
+ ReleaseDate: strings.TrimSpace(album.ReleaseDate),
+ Artists: tidalAlbumArtistsDisplay(album),
+ ArtistId: artistID,
+ Images: tidalImageURL(album.Cover, "1280x1280"),
+ }
+}
+
+func tidalAlbumToArtistAlbum(album *tidalPublicAlbum) ArtistAlbumMetadata {
+ return tidalAlbumToArtistAlbumWithType(album, "")
+}
+
+func tidalAlbumToArtistAlbumWithType(album *tidalPublicAlbum, fallbackType string) ArtistAlbumMetadata {
+ if album == nil {
+ return ArtistAlbumMetadata{}
+ }
+
+ albumType := strings.ToLower(strings.TrimSpace(album.Type))
+ if albumType == "" {
+ albumType = strings.ToLower(strings.TrimSpace(fallbackType))
+ }
+ if albumType == "" {
+ albumType = "album"
+ }
+
+ return ArtistAlbumMetadata{
+ ID: tidalPrefixedNumericID(album.ID),
+ Name: strings.TrimSpace(album.Title),
+ ReleaseDate: strings.TrimSpace(album.ReleaseDate),
+ TotalTracks: album.NumberOfTracks,
+ Images: tidalImageURL(album.Cover, "1280x1280"),
+ AlbumType: albumType,
+ Artists: tidalAlbumArtistsDisplay(album),
+ }
+}
+
+func tidalPlaylistOwnerName(playlist *tidalPublicPlaylist) string {
+ if playlist == nil {
+ return ""
+ }
+ if trimmed := strings.TrimSpace(playlist.Creator.Name); trimmed != "" {
+ return trimmed
+ }
+ if strings.EqualFold(strings.TrimSpace(playlist.Type), "ARTIST") {
+ return "Artist"
+ }
+ return "TIDAL"
+}
+
+func tidalArtistAlbumTypeFromModuleTitle(title string) string {
+ normalized := strings.ToLower(strings.TrimSpace(title))
+ switch normalized {
+ case "albums", "compilations", "appears on":
+ return "album"
+ case "ep & singles", "eps & singles", "singles", "ep", "eps":
+ return "single"
+ default:
+ return ""
+ }
+}
+
+func tidalBuildMetadataURL(path string, extraQuery url.Values) string {
+ trimmedPath := strings.TrimLeft(strings.TrimSpace(path), "/")
+ if trimmedPath == "" {
+ return tidalPublicAPIBaseURL
+ }
+
+ baseURL, err := url.Parse(tidalPublicAPIBaseURL + "/" + trimmedPath)
+ if err != nil {
+ return tidalPublicAPIBaseURL + "/" + trimmedPath
+ }
+
+ query := baseURL.Query()
+ query.Set("countryCode", tidalCountryCode)
+ query.Set("locale", tidalLocale)
+ query.Set("deviceType", tidalDeviceType)
+ for key, values := range extraQuery {
+ query.Del(key)
+ for _, value := range values {
+ query.Add(key, value)
+ }
+ }
+ baseURL.RawQuery = query.Encode()
+ return baseURL.String()
+}
+
+func (t *TidalDownloader) getTidalMetadataJSON(requestURL string, target interface{}) error {
+ req, err := http.NewRequest("GET", requestURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("x-tidal-token", tidalPublicToken)
+
+ resp, err := DoRequestWithUserAgent(t.client, req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return fmt.Errorf("tidal metadata request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ return json.NewDecoder(resp.Body).Decode(target)
+}
+
+func (t *TidalDownloader) getPublicTrack(resourceID string) (*TidalTrack, error) {
+ trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64)
+ if err != nil || trackID <= 0 {
+ return nil, fmt.Errorf("invalid tidal track ID: %s", resourceID)
+ }
+
+ requestURL := tidalBuildMetadataURL(fmt.Sprintf("tracks/%d", trackID), nil)
+ var track TidalTrack
+ if err := t.getTidalMetadataJSON(requestURL, &track); err != nil {
+ return nil, err
+ }
+ return &track, nil
+}
+
+func (t *TidalDownloader) getAlbumPage(resourceID string) (*tidalPublicAlbumPage, error) {
+ albumID := strings.TrimSpace(resourceID)
+ if albumID == "" {
+ return nil, fmt.Errorf("invalid tidal album ID")
+ }
+
+ requestURL := tidalBuildMetadataURL("pages/album", url.Values{"albumId": {albumID}})
+ var page tidalPublicAlbumPage
+ if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
+ return nil, err
+ }
+ return &page, nil
+}
+
+func (t *TidalDownloader) getArtistPage(resourceID string) (*tidalPublicArtistPage, error) {
+ artistID := strings.TrimSpace(resourceID)
+ if artistID == "" {
+ return nil, fmt.Errorf("invalid tidal artist ID")
+ }
+
+ requestURL := tidalBuildMetadataURL("pages/artist", url.Values{"artistId": {artistID}})
+ var page tidalPublicArtistPage
+ if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
+ return nil, err
+ }
+ return &page, nil
+}
+
+func (t *TidalDownloader) getArtistAlbumsPage(dataAPIPath string, offset, limit int) (*tidalPublicArtistAlbumsPage, error) {
+ extraQuery := url.Values{}
+ if offset >= 0 {
+ extraQuery.Set("offset", strconv.Itoa(offset))
+ }
+ if limit > 0 {
+ extraQuery.Set("limit", strconv.Itoa(limit))
+ }
+
+ requestURL := tidalBuildMetadataURL(dataAPIPath, extraQuery)
+ var page tidalPublicArtistAlbumsPage
+ if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
+ return nil, err
+ }
+ return &page, nil
+}
+
+func (t *TidalDownloader) getPlaylist(resourceID string) (*tidalPublicPlaylist, error) {
+ playlistID := strings.TrimSpace(resourceID)
+ if playlistID == "" {
+ return nil, fmt.Errorf("invalid tidal playlist ID")
+ }
+
+ requestURL := tidalBuildMetadataURL("playlists/"+url.PathEscape(playlistID), nil)
+ var playlist tidalPublicPlaylist
+ if err := t.getTidalMetadataJSON(requestURL, &playlist); err != nil {
+ return nil, err
+ }
+ return &playlist, nil
+}
+
+func (t *TidalDownloader) getPlaylistItemsPage(resourceID string, offset, limit int) (*tidalPublicPlaylistItemsPage, error) {
+ playlistID := strings.TrimSpace(resourceID)
+ if playlistID == "" {
+ return nil, fmt.Errorf("invalid tidal playlist ID")
+ }
+
+ requestURL := tidalBuildMetadataURL(
+ "playlists/"+url.PathEscape(playlistID)+"/items",
+ url.Values{
+ "offset": {strconv.Itoa(offset)},
+ "limit": {strconv.Itoa(limit)},
+ },
+ )
+ var page tidalPublicPlaylistItemsPage
+ if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
+ return nil, err
+ }
+ return &page, nil
+}
+
+func (t *TidalDownloader) getTrackSearchPage(query string, limit int) (*tidalPublicTrackSearchResponse, error) {
+ cleanQuery := strings.TrimSpace(query)
+ if cleanQuery == "" {
+ return nil, fmt.Errorf("empty tidal search query")
+ }
+ if limit <= 0 {
+ limit = 20
+ }
+
+ requestURL := tidalBuildMetadataURL(
+ "search/tracks",
+ url.Values{
+ "query": {cleanQuery},
+ "limit": {strconv.Itoa(limit)},
+ "offset": {"0"},
+ },
+ )
+ var page tidalPublicTrackSearchResponse
+ if err := t.getTidalMetadataJSON(requestURL, &page); err != nil {
+ return nil, err
+ }
+ return &page, nil
+}
+
+func findTidalAlbumPageModule(page *tidalPublicAlbumPage, moduleType string) *struct {
+ Type string `json:"type"`
+ Album tidalPublicAlbum `json:"album"`
+ PagedList struct {
+ DataAPIPath string `json:"dataApiPath"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ TotalNumberOfItems int `json:"totalNumberOfItems"`
+ Items []struct {
+ Item TidalTrack `json:"item"`
+ Type string `json:"type"`
+ } `json:"items"`
+ } `json:"pagedList"`
+} {
+ if page == nil {
+ return nil
+ }
+ for rowIndex := range page.Rows {
+ for moduleIndex := range page.Rows[rowIndex].Modules {
+ module := &page.Rows[rowIndex].Modules[moduleIndex]
+ if module.Type == moduleType {
+ return module
+ }
+ }
+ }
+ return nil
+}
+
+func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Artist struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Picture string `json:"picture"`
+ } `json:"artist"`
+ PagedList struct {
+ DataAPIPath string `json:"dataApiPath"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ TotalNumberOfItems int `json:"totalNumberOfItems"`
+ Items []tidalPublicAlbum `json:"items"`
+ } `json:"pagedList"`
+} {
+ if page == nil {
+ return nil
+ }
+ for rowIndex := range page.Rows {
+ for moduleIndex := range page.Rows[rowIndex].Modules {
+ module := &page.Rows[rowIndex].Modules[moduleIndex]
+ if module.Type == moduleType {
+ return module
+ }
+ }
+ }
+ return nil
+}
+
func (t *TidalDownloader) GetAvailableAPIs() []string {
return []string{
"https://tidal-api.binimum.org",
@@ -192,15 +764,393 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
}
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
- return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
+ normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
+ if normalizedISRC == "" {
+ return nil, fmt.Errorf("empty tidal ISRC")
+ }
+
+ page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
+ if err != nil {
+ return nil, err
+ }
+
+ for i := range page.Items {
+ if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
+ return &page.Items[i], nil
+ }
+ }
+
+ return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
}
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
- return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
+ queryParts := make([]string, 0, 3)
+ if trimmed := strings.TrimSpace(trackName); trimmed != "" {
+ queryParts = append(queryParts, trimmed)
+ }
+ if trimmed := strings.TrimSpace(artistName); trimmed != "" {
+ queryParts = append(queryParts, trimmed)
+ }
+ if len(queryParts) == 0 {
+ return nil, fmt.Errorf("tidal metadata search requires track or artist name")
+ }
+
+ queries := []string{strings.Join(queryParts, " ")}
+ if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
+ queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
+ }
+
+ req := DownloadRequest{
+ TrackName: strings.TrimSpace(trackName),
+ ArtistName: strings.TrimSpace(artistName),
+ AlbumName: strings.TrimSpace(albumName),
+ ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
+ DurationMS: expectedDuration * 1000,
+ }
+
+ seenQueries := make(map[string]struct{}, len(queries))
+ for _, query := range queries {
+ if _, seen := seenQueries[query]; seen {
+ continue
+ }
+ seenQueries[query] = struct{}{}
+
+ page, err := tidalGetTrackSearchPageFunc(t, query, 20)
+ if err != nil {
+ return nil, err
+ }
+
+ var candidates []*TidalTrack
+ for i := range page.Items {
+ track := &page.Items[i]
+ if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
+ continue
+ }
+ resolved := resolvedTrackInfo{
+ Title: strings.TrimSpace(track.Title),
+ ArtistName: tidalTrackArtistsDisplay(track),
+ Duration: track.Duration,
+ }
+ if trackMatchesRequest(req, resolved, "Tidal search") {
+ candidates = append(candidates, track)
+ }
+ }
+
+ if len(candidates) == 0 {
+ continue
+ }
+
+ if req.AlbumName != "" {
+ for _, candidate := range candidates {
+ if titlesMatch(req.AlbumName, candidate.Album.Title) {
+ return candidate, nil
+ }
+ }
+ }
+
+ return candidates[0], nil
+ }
+
+ if req.ISRC != "" {
+ return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
+ }
+ return nil, fmt.Errorf("no tidal metadata match found")
}
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
- return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
+ return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
+}
+
+func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
+ page, err := t.getTrackSearchPage(query, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]ExtTrackMetadata, 0, len(page.Items))
+ for i := range page.Items {
+ results = append(results, normalizeBuiltInMetadataTrack(tidalTrackToTrackMetadata(&page.Items[i]), "tidal"))
+ }
+ return results, nil
+}
+
+// SearchAll searches Tidal for tracks, artists, and albums matching the query.
+// Returns results in the same SearchAllResult format as Deezer's SearchAll.
+func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
+ GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
+
+ cleanQuery := strings.TrimSpace(query)
+ if cleanQuery == "" {
+ return nil, fmt.Errorf("empty tidal search query")
+ }
+
+ albumLimit := 5
+
+ if filter != "" {
+ switch filter {
+ case "track":
+ trackLimit = 50
+ artistLimit = 0
+ albumLimit = 0
+ case "artist":
+ trackLimit = 0
+ artistLimit = 20
+ albumLimit = 0
+ case "album":
+ trackLimit = 0
+ artistLimit = 0
+ albumLimit = 20
+ }
+ }
+
+ result := &SearchAllResult{
+ Tracks: make([]TrackMetadata, 0, trackLimit),
+ Artists: make([]SearchArtistResult, 0, artistLimit),
+ Albums: make([]SearchAlbumResult, 0, albumLimit),
+ Playlists: make([]SearchPlaylistResult, 0),
+ }
+
+ if trackLimit > 0 {
+ page, err := t.getTrackSearchPage(cleanQuery, trackLimit)
+ if err != nil {
+ GoLog("[Tidal] Track search failed: %v\n", err)
+ return nil, fmt.Errorf("tidal track search failed: %w", err)
+ }
+ GoLog("[Tidal] Got %d tracks from API\n", len(page.Items))
+ for i := range page.Items {
+ result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i]))
+ }
+ }
+
+ if artistLimit > 0 {
+ requestURL := tidalBuildMetadataURL("search/artists", url.Values{
+ "query": {cleanQuery},
+ "limit": {strconv.Itoa(artistLimit)},
+ "offset": {"0"},
+ })
+ var artistResp struct {
+ Items []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Picture string `json:"picture"`
+ Popularity int `json:"popularity"`
+ URL string `json:"url"`
+ } `json:"items"`
+ }
+ if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil {
+ GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items))
+ for _, artist := range artistResp.Items {
+ result.Artists = append(result.Artists, SearchArtistResult{
+ ID: tidalPrefixedNumericID(artist.ID),
+ Name: strings.TrimSpace(artist.Name),
+ Images: tidalImageURL(artist.Picture, "750x750"),
+ Followers: 0,
+ Popularity: artist.Popularity,
+ })
+ }
+ } else {
+ GoLog("[Tidal] Artist search failed: %v\n", err)
+ }
+ }
+
+ if albumLimit > 0 {
+ requestURL := tidalBuildMetadataURL("search/albums", url.Values{
+ "query": {cleanQuery},
+ "limit": {strconv.Itoa(albumLimit)},
+ "offset": {"0"},
+ })
+ var albumResp struct {
+ Items []tidalPublicAlbum `json:"items"`
+ }
+ if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil {
+ GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items))
+ for i := range albumResp.Items {
+ album := &albumResp.Items[i]
+ albumType := strings.ToLower(strings.TrimSpace(album.Type))
+ if albumType == "" {
+ albumType = "album"
+ }
+ result.Albums = append(result.Albums, SearchAlbumResult{
+ ID: tidalPrefixedNumericID(album.ID),
+ Name: strings.TrimSpace(album.Title),
+ Artists: tidalAlbumArtistsDisplay(album),
+ Images: tidalImageURL(album.Cover, "1280x1280"),
+ ReleaseDate: strings.TrimSpace(album.ReleaseDate),
+ TotalTracks: album.NumberOfTracks,
+ AlbumType: albumType,
+ })
+ }
+ } else {
+ GoLog("[Tidal] Album search failed: %v\n", err)
+ }
+ }
+
+ GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
+ return result, nil
+}
+
+func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
+ track, err := t.getPublicTrack(resourceID)
+ if err != nil {
+ return nil, err
+ }
+ return &TrackResponse{Track: tidalTrackToTrackMetadata(track)}, nil
+}
+
+func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) {
+ page, err := t.getAlbumPage(resourceID)
+ if err != nil {
+ return nil, err
+ }
+
+ headerModule := findTidalAlbumPageModule(page, "ALBUM_HEADER")
+ itemsModule := findTidalAlbumPageModule(page, "ALBUM_ITEMS")
+ if headerModule == nil {
+ return nil, fmt.Errorf("tidal album page missing album header")
+ }
+ if itemsModule == nil {
+ return nil, fmt.Errorf("tidal album page missing track list")
+ }
+
+ 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
+ }
+ tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
+ }
+
+ return &AlbumResponsePayload{
+ AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
+ TrackList: tracks,
+ }, nil
+}
+
+func (t *TidalDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) {
+ playlist, err := t.getPlaylist(resourceID)
+ if err != nil {
+ return nil, err
+ }
+
+ const pageSize = 50
+ offset := 0
+ totalTracks := playlist.NumberOfTracks
+ tracks := make([]AlbumTrackMetadata, 0, totalTracks)
+
+ for {
+ page, pageErr := t.getPlaylistItemsPage(resourceID, offset, pageSize)
+ if pageErr != nil {
+ return nil, pageErr
+ }
+ if totalTracks == 0 && page.TotalNumberOfItems > 0 {
+ totalTracks = page.TotalNumberOfItems
+ }
+
+ for _, item := range page.Items {
+ if item.Type != "track" {
+ continue
+ }
+ tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&item.Item))
+ }
+
+ if len(page.Items) == 0 || offset+len(page.Items) >= totalTracks || len(page.Items) < pageSize {
+ break
+ }
+ offset += len(page.Items)
+ }
+
+ var info PlaylistInfoMetadata
+ info.Tracks.Total = totalTracks
+ info.Name = strings.TrimSpace(playlist.Title)
+ info.Images = tidalImageURL(tidalFirstNonEmpty(playlist.SquareImage, playlist.Image), "origin")
+ info.Owner.DisplayName = tidalPlaylistOwnerName(playlist)
+ info.Owner.Name = strings.TrimSpace(playlist.Title)
+ info.Owner.Images = info.Images
+
+ return &PlaylistResponsePayload{
+ PlaylistInfo: info,
+ TrackList: tracks,
+ }, nil
+}
+
+func (t *TidalDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) {
+ page, err := t.getArtistPage(resourceID)
+ if err != nil {
+ return nil, err
+ }
+
+ headerModule := findTidalArtistPageModule(page, "ARTIST_HEADER")
+ albumsModule := findTidalArtistPageModule(page, "ALBUM_LIST")
+ if headerModule == nil {
+ return nil, fmt.Errorf("tidal artist page missing artist header")
+ }
+ if albumsModule == nil {
+ return nil, fmt.Errorf("tidal artist page missing albums list")
+ }
+
+ albums := make([]ArtistAlbumMetadata, 0, albumsModule.PagedList.TotalNumberOfItems)
+ seenAlbumIDs := make(map[string]struct{})
+
+ appendArtistAlbum := func(album tidalPublicAlbum, fallbackType string) {
+ mapped := tidalAlbumToArtistAlbumWithType(&album, fallbackType)
+ if mapped.ID == "" {
+ return
+ }
+ if _, exists := seenAlbumIDs[mapped.ID]; exists {
+ return
+ }
+ seenAlbumIDs[mapped.ID] = struct{}{}
+ albums = append(albums, mapped)
+ }
+
+ for rowIndex := range page.Rows {
+ for moduleIndex := range page.Rows[rowIndex].Modules {
+ module := &page.Rows[rowIndex].Modules[moduleIndex]
+ if module.Type != "ALBUM_LIST" {
+ continue
+ }
+
+ fallbackType := tidalArtistAlbumTypeFromModuleTitle(module.Title)
+ for _, album := range module.PagedList.Items {
+ appendArtistAlbum(album, fallbackType)
+ }
+
+ pageSize := module.PagedList.Limit
+ if pageSize <= 0 {
+ pageSize = 50
+ }
+ offset := len(module.PagedList.Items)
+ for offset < module.PagedList.TotalNumberOfItems && strings.TrimSpace(module.PagedList.DataAPIPath) != "" {
+ albumsPage, pageErr := t.getArtistAlbumsPage(module.PagedList.DataAPIPath, offset, pageSize)
+ if pageErr != nil {
+ return nil, pageErr
+ }
+
+ for _, album := range albumsPage.Items {
+ appendArtistAlbum(album, fallbackType)
+ }
+
+ if len(albumsPage.Items) == 0 || offset+len(albumsPage.Items) >= albumsPage.TotalNumberOfItems {
+ break
+ }
+ offset += len(albumsPage.Items)
+ }
+ }
+ }
+
+ return &ArtistResponsePayload{
+ ArtistInfo: ArtistInfoMetadata{
+ ID: tidalPrefixedNumericID(headerModule.Artist.ID),
+ Name: strings.TrimSpace(headerModule.Artist.Name),
+ Images: tidalImageURL(headerModule.Artist.Picture, "750x750"),
+ },
+ Albums: albums,
+ }, nil
}
type TidalDownloadInfo struct {
@@ -583,7 +1533,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
directURL != "", initURL != "", len(mediaURLs))
- client := NewHTTPClientWithTimeout(120 * time.Second)
+ client := NewHTTPClientWithTimeout(DownloadTimeout)
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
@@ -794,8 +1744,8 @@ type TidalDownloadResult struct {
}
func artistsMatch(spotifyArtist, tidalArtist string) bool {
- normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
- normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
+ normSpotify := normalizeLooseArtistName(spotifyArtist)
+ normTidal := normalizeLooseArtistName(tidalArtist)
if normSpotify == normTidal {
return true
@@ -1062,20 +2012,18 @@ func isLatinScript(s string) bool {
return true
}
-func tidalTrackArtistsDisplay(track *TidalTrack) string {
- if track == nil {
- return ""
+func parseTidalRequestTrackID(raw string) (int64, bool) {
+ trimmed := strings.TrimSpace(raw)
+ trimmed = strings.TrimPrefix(trimmed, "tidal:")
+ if trimmed == "" {
+ return 0, false
}
- tidalArtist := track.Artist.Name
- if len(track.Artists) > 0 {
- var artistNames []string
- for _, a := range track.Artists {
- artistNames = append(artistNames, a.Name)
- }
- tidalArtist = strings.Join(artistNames, ", ")
+ trackID, err := strconv.ParseInt(trimmed, 10, 64)
+ if err != nil || trackID <= 0 {
+ return 0, false
}
- return tidalArtist
+ return trackID, true
}
func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) {
@@ -1091,8 +2039,9 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
var gotTidalID bool
if req.TidalID != "" {
- GoLog("[%s] Using Tidal ID from Odesli enrichment: %s\n", logPrefix, req.TidalID)
- if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
+ GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
+ if parsedTrackID, ok := parseTidalRequestTrackID(req.TidalID); ok {
+ trackID = parsedTrackID
gotTidalID = true
}
}
@@ -1105,6 +2054,36 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
}
}
+ if !gotTidalID && req.ISRC != "" {
+ GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
+ directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
+ if directErr == nil && directTrack != nil && directTrack.ID > 0 {
+ trackID = directTrack.ID
+ gotTidalID = true
+ GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
+ } else if directErr != nil {
+ GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
+ }
+ }
+
+ if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
+ GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
+ searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
+ req.TrackName,
+ req.ArtistName,
+ req.AlbumName,
+ req.ISRC,
+ expectedDurationSec,
+ )
+ if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
+ trackID = searchTrack.ID
+ gotTidalID = true
+ GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
+ } else if searchErr != nil {
+ GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
+ }
+ }
+
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
@@ -1113,7 +2092,8 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return
}
if availability.TidalID != "" {
- if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
+ if parsedTrackID, ok := parseTidalRequestTrackID(availability.TidalID); ok {
+ trackID = parsedTrackID
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true
return
@@ -1168,6 +2148,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
}
+ // Verify the resolved track matches the request.
+ actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
+ if fetchErr != nil {
+ GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
+ // Continue without verification — better than failing entirely.
+ } else {
+ providerArtist := actualTrack.Artist.Name
+ if providerArtist == "" && len(actualTrack.Artists) > 0 {
+ providerArtist = actualTrack.Artists[0].Name
+ }
+ resolved := resolvedTrackInfo{
+ Title: actualTrack.Title,
+ ArtistName: providerArtist,
+ Duration: actualTrack.Duration,
+ }
+ if !trackMatchesRequest(req, resolved, logPrefix) {
+ // Invalidate the cached ID so future requests don't reuse it.
+ if req.ISRC != "" {
+ GetTrackIDCache().SetTidal(req.ISRC, 0)
+ }
+ return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'",
+ trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
+ }
+ GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
+ }
+
track := &TidalTrack{
ID: trackID,
Title: strings.TrimSpace(req.TrackName),
@@ -1437,25 +2443,33 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
- lyricsLRC := ""
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
}
+ lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
+ resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
+ req,
+ track.Album.Title,
+ track.Album.ReleaseDate,
+ actualTrackNumber,
+ actualDiscNumber,
+ )
+
return TidalDownloadResult{
FilePath: actualOutputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: track.Title,
Artist: track.Artist.Name,
- Album: track.Album.Title,
- ReleaseDate: track.Album.ReleaseDate,
- TrackNumber: actualTrackNumber,
- DiscNumber: actualDiscNumber,
+ Album: resultAlbum,
+ ReleaseDate: resultReleaseDate,
+ TrackNumber: resultTrackNumber,
+ DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
diff --git a/go_backend/tidal_test.go b/go_backend/tidal_test.go
new file mode 100644
index 00000000..bc8a56dc
--- /dev/null
+++ b/go_backend/tidal_test.go
@@ -0,0 +1,222 @@
+package gobackend
+
+import "testing"
+
+func TestParseTidalURL(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantType string
+ wantID string
+ expectErr bool
+ }{
+ {
+ name: "track url",
+ input: "https://tidal.com/track/77616174",
+ wantType: "track",
+ wantID: "77616174",
+ },
+ {
+ name: "browse album url",
+ input: "https://listen.tidal.com/browse/album/77616169",
+ wantType: "album",
+ wantID: "77616169",
+ },
+ {
+ name: "artist url",
+ input: "https://www.tidal.com/artist/3852143",
+ wantType: "artist",
+ wantID: "3852143",
+ },
+ {
+ name: "playlist url",
+ input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
+ wantType: "playlist",
+ wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
+ },
+ {
+ name: "unsupported host",
+ input: "https://example.com/track/123",
+ expectErr: true,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ gotType, gotID, err := parseTidalURL(test.input)
+ if test.expectErr {
+ if err == nil {
+ t.Fatalf("expected error, got none")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if gotType != test.wantType || gotID != test.wantID {
+ t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
+ }
+ })
+ }
+}
+
+func TestParseTidalRequestTrackID(t *testing.T) {
+ tests := []struct {
+ input string
+ want int64
+ ok bool
+ }{
+ {input: "40681594", want: 40681594, ok: true},
+ {input: "tidal:40681594", want: 40681594, ok: true},
+ {input: " tidal:40681594 ", want: 40681594, ok: true},
+ {input: "", want: 0, ok: false},
+ {input: "tidal:not-a-number", want: 0, ok: false},
+ }
+
+ for _, test := range tests {
+ got, ok := parseTidalRequestTrackID(test.input)
+ if got != test.want || ok != test.ok {
+ t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
+ }
+ }
+}
+
+func TestTidalImageURL(t *testing.T) {
+ got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
+ want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
+ if got != want {
+ t.Fatalf("tidalImageURL() = %q, want %q", got, want)
+ }
+}
+
+func TestTidalTrackToTrackMetadata(t *testing.T) {
+ track := &TidalTrack{
+ ID: 77616174,
+ Title: "Bruckner: Symphony No. 5",
+ ISRC: "GBUM71507433",
+ Duration: 1172,
+ TrackNumber: 5,
+ VolumeNumber: 1,
+ URL: "http://www.tidal.com/track/77616174",
+ }
+ track.Artist.ID = 3852143
+ track.Artist.Name = "Staatskapelle Berlin"
+ track.Artists = []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Picture string `json:"picture"`
+ }{
+ {ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
+ {ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
+ }
+ track.Album.ID = 77616169
+ track.Album.Title = "Bruckner: Symphonies 4-9"
+ track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
+ track.Album.ReleaseDate = "2016-02-26"
+
+ got := tidalTrackToTrackMetadata(track)
+ if got.SpotifyID != "tidal:77616174" {
+ t.Fatalf("unexpected track ID: %q", got.SpotifyID)
+ }
+ if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
+ t.Fatalf("unexpected artists: %q", got.Artists)
+ }
+ if got.AlbumID != "tidal:77616169" {
+ t.Fatalf("unexpected album ID: %q", got.AlbumID)
+ }
+ if got.ArtistID != "tidal:3852143" {
+ t.Fatalf("unexpected artist ID: %q", got.ArtistID)
+ }
+ if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
+ t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
+ }
+}
+
+func TestTidalAlbumToArtistAlbum(t *testing.T) {
+ album := &tidalPublicAlbum{
+ ID: 77616169,
+ Title: "Bruckner: Symphonies 4-9",
+ Type: "ALBUM",
+ Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
+ ReleaseDate: "2016-02-26",
+ NumberOfTracks: 23,
+ Artists: []tidalPublicArtist{
+ {ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
+ {ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
+ },
+ }
+
+ got := tidalAlbumToArtistAlbum(album)
+ if got.ID != "tidal:77616169" {
+ t.Fatalf("unexpected album ID: %q", got.ID)
+ }
+ if got.AlbumType != "album" {
+ t.Fatalf("unexpected album type: %q", got.AlbumType)
+ }
+ if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
+ t.Fatalf("unexpected artists: %q", got.Artists)
+ }
+ if got.Images == "" {
+ t.Fatalf("expected image URL, got empty string")
+ }
+}
+
+func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
+ album := &tidalPublicAlbum{
+ ID: 490623904,
+ Title: "LET 'EM KNOW",
+ Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
+ NumberOfTracks: 1,
+ }
+
+ got := tidalAlbumToArtistAlbumWithType(album, "single")
+ if got.AlbumType != "single" {
+ t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
+ }
+}
+
+func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
+ tests := []struct {
+ title string
+ want string
+ }{
+ {title: "Albums", want: "album"},
+ {title: "EP & Singles", want: "single"},
+ {title: "Compilations", want: "album"},
+ {title: "Appears On", want: "album"},
+ {title: "Unknown", want: ""},
+ }
+
+ for _, test := range tests {
+ if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
+ t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
+ }
+ }
+}
+
+func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
+ got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
+ want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
+ if got != want {
+ t.Fatalf("unexpected origin playlist image URL: %q", got)
+ }
+}
+
+func TestTidalPlaylistOwnerName(t *testing.T) {
+ editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
+ if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
+ t.Fatalf("unexpected editorial owner: %q", got)
+ }
+
+ artist := &tidalPublicPlaylist{Type: "ARTIST"}
+ if got := tidalPlaylistOwnerName(artist); got != "Artist" {
+ t.Fatalf("unexpected artist owner: %q", got)
+ }
+
+ user := &tidalPublicPlaylist{}
+ user.Creator.Name = "djtest"
+ if got := tidalPlaylistOwnerName(user); got != "djtest" {
+ t.Fatalf("unexpected creator owner: %q", got)
+ }
+}
diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go
index 039ff434..cecf462d 100644
--- a/go_backend/title_match_utils.go
+++ b/go_backend/title_match_utils.go
@@ -3,6 +3,8 @@ package gobackend
import (
"strings"
"unicode"
+
+ "golang.org/x/text/unicode/norm"
)
// normalizeLooseTitle collapses separators/punctuation so titles like
@@ -33,6 +35,37 @@ func normalizeLooseTitle(title string) string {
return strings.Join(strings.Fields(b.String()), " ")
}
+// normalizeLooseArtistName folds diacritics and common separators so artist
+// verification is resilient to variants like "Özkent" vs "Ozkent".
+func normalizeLooseArtistName(name string) string {
+ trimmed := strings.TrimSpace(strings.ToLower(name))
+ if trimmed == "" {
+ return ""
+ }
+
+ decomposed := norm.NFD.String(trimmed)
+
+ var b strings.Builder
+ b.Grow(len(decomposed))
+
+ for _, r := range decomposed {
+ switch {
+ case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
+ continue
+ case unicode.IsLetter(r), unicode.IsNumber(r):
+ b.WriteRune(r)
+ case unicode.IsSpace(r):
+ b.WriteByte(' ')
+ case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
+ b.WriteByte(' ')
+ default:
+ // Drop remaining punctuation/symbols for loose artist matching.
+ }
+ }
+
+ return strings.Join(strings.Fields(b.String()), " ")
+}
+
func hasAlphaNumericRunes(value string) bool {
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
@@ -68,3 +101,45 @@ 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
+}
+
+// trackMatchesRequest checks whether a resolved track from a provider matches
+// the original download request. Returns true if the track is a plausible match.
+func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
+ if req.ArtistName != "" && resolved.ArtistName != "" &&
+ !artistsMatch(req.ArtistName, resolved.ArtistName) {
+ GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
+ logPrefix, req.ArtistName, resolved.ArtistName)
+ return false
+ }
+
+ if req.TrackName != "" && resolved.Title != "" &&
+ !titlesMatch(req.TrackName, resolved.Title) {
+ GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
+ logPrefix, req.TrackName, resolved.Title)
+ return false
+ }
+
+ expectedDurationSec := req.DurationMS / 1000
+ if expectedDurationSec > 0 && resolved.Duration > 0 {
+ diff := expectedDurationSec - resolved.Duration
+ if diff < 0 {
+ diff = -diff
+ }
+ if diff > 10 {
+ GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
+ logPrefix, expectedDurationSec, resolved.Duration)
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/go_backend/youtube.go b/go_backend/youtube.go
index e43d0e39..3bb65f5a 100644
--- a/go_backend/youtube.go
+++ b/go_backend/youtube.go
@@ -11,7 +11,6 @@ import (
"strconv"
"strings"
"sync"
- "time"
)
type YouTubeDownloader struct {
@@ -30,6 +29,7 @@ var (
type YouTubeQuality string
const (
+ YouTubeQualityOpus320 YouTubeQuality = "opus_320"
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
@@ -38,7 +38,7 @@ const (
)
var (
- youtubeOpusSupportedBitrates = []int{128, 256}
+ youtubeOpusSupportedBitrates = []int{128, 256, 320}
youtubeMp3SupportedBitrates = []int{128, 256, 320}
)
@@ -82,7 +82,7 @@ type YouTubeDownloadResult struct {
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
- client: NewHTTPClientWithTimeout(120 * time.Second),
+ client: NewHTTPClientWithTimeout(DownloadTimeout),
apiURL: "https://api.qwkuns.me",
}
})
@@ -147,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
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", "":
@@ -511,12 +513,10 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
return "", fmt.Errorf("invalid URL: %w", err)
}
- // /watch?v=
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
- // /embed/
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
@@ -524,7 +524,6 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
}
}
- // /v/
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
diff --git a/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go
index cb77e5bb..e0f2ebbf 100644
--- a/go_backend/youtube_quality_test.go
+++ b/go_backend/youtube_quality_test.go
@@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T)
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
- if opusBitrate != 256 {
- t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
+ if opusBitrate != 320 {
+ t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
}
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
@@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
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/image.png b/image.png
deleted file mode 100644
index 565fab55..00000000
Binary files a/image.png and /dev/null differ
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 4cb53238..740a9fd3 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -160,38 +160,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
- case "getSpotifyMetadata":
- let args = call.arguments as! [String: Any]
- let url = args["url"] as! String
- let response = GobackendGetSpotifyMetadata(url, &error)
- if let error = error { throw error }
- return response
-
- case "searchSpotify":
- let args = call.arguments as! [String: Any]
- let query = args["query"] as! String
- let limit = args["limit"] as? Int ?? 10
- let response = GobackendSearchSpotify(query, Int(limit), &error)
- if let error = error { throw error }
- return response
-
- case "searchSpotifyAll":
- let args = call.arguments as! [String: Any]
- let query = args["query"] as! String
- let trackLimit = args["track_limit"] as? Int ?? 15
- let artistLimit = args["artist_limit"] as? Int ?? 3
- let response = GobackendSearchSpotifyAll(query, Int(trackLimit), Int(artistLimit), &error)
- if let error = error { throw error }
- return response
-
- case "getSpotifyRelatedArtists":
- let args = call.arguments as! [String: Any]
- let artistId = args["artist_id"] as! String
- let limit = args["limit"] as? Int ?? 12
- let response = GobackendGetSpotifyRelatedArtists(artistId, Int(limit), &error)
- if let error = error { throw error }
- return response
-
case "checkAvailability":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
@@ -399,6 +367,26 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ case "searchTidalAll":
+ let args = call.arguments as! [String: Any]
+ let query = args["query"] as! String
+ let trackLimit = args["track_limit"] as? Int ?? 15
+ let artistLimit = args["artist_limit"] as? Int ?? 3
+ let filter = args["filter"] as? String ?? ""
+ let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
+ if let error = error { throw error }
+ return response
+
+ case "searchQobuzAll":
+ let args = call.arguments as! [String: Any]
+ let query = args["query"] as! String
+ let trackLimit = args["track_limit"] as? Int ?? 15
+ let artistLimit = args["artist_limit"] as? Int ?? 3
+ let filter = args["filter"] as? String ?? ""
+ let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
+ if let error = error { throw error }
+ return response
+
case "getDeezerRelatedArtists":
let args = call.arguments as! [String: Any]
let artistId = args["artist_id"] as! String
@@ -415,6 +403,22 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ case "getQobuzMetadata":
+ let args = call.arguments as! [String: Any]
+ let resourceType = args["resource_type"] as! String
+ let resourceId = args["resource_id"] as! String
+ let response = GobackendGetQobuzMetadata(resourceType, resourceId, &error)
+ if let error = error { throw error }
+ return response
+
+ case "getTidalMetadata":
+ let args = call.arguments as! [String: Any]
+ let resourceType = args["resource_type"] as! String
+ let resourceId = args["resource_id"] as! String
+ let response = GobackendGetTidalMetadata(resourceType, resourceId, &error)
+ if let error = error { throw error }
+ return response
+
case "parseDeezerUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
@@ -422,6 +426,13 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ case "parseQobuzUrl":
+ let args = call.arguments as! [String: Any]
+ let url = args["url"] as! String
+ let response = GobackendParseQobuzURLExport(url, &error)
+ if let error = error { throw error }
+ return response
+
case "parseTidalUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
@@ -510,17 +521,6 @@ import Gobackend // Import Go framework
GobackendClearTrackCache()
return nil
- case "setSpotifyCredentials":
- let args = call.arguments as! [String: Any]
- let clientId = args["client_id"] as! String
- let clientSecret = args["client_secret"] as! String
- GobackendSetSpotifyAPICredentials(clientId, clientSecret)
- return nil
-
- case "hasSpotifyCredentials":
- let hasCredentials = GobackendCheckSpotifyCredentials()
- return hasCredentials
-
// Log methods
case "getLogs":
let response = GobackendGetLogs()
@@ -643,6 +643,20 @@ import Gobackend // Import Go framework
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
if let error = error { throw error }
return response
+
+ case "searchTracksWithMetadataProviders":
+ let args = call.arguments as! [String: Any]
+ let query = args["query"] as! String
+ let limit = args["limit"] as? Int ?? 20
+ let includeExtensions = args["include_extensions"] as? Bool ?? true
+ let response = GobackendSearchTracksWithMetadataProvidersJSON(
+ query,
+ Int(limit),
+ includeExtensions,
+ &error
+ )
+ if let error = error { throw error }
+ return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
@@ -834,6 +848,23 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return nil
+ case "setStoreRegistryUrl":
+ let args = call.arguments as! [String: Any]
+ let registryUrl = args["registry_url"] as? String ?? ""
+ GobackendSetStoreRegistryURLJSON(registryUrl, &error)
+ if let error = error { throw error }
+ return nil
+
+ case "getStoreRegistryUrl":
+ let response = GobackendGetStoreRegistryURLJSON(&error)
+ if let error = error { throw error }
+ return response
+
+ case "clearStoreRegistryUrl":
+ GobackendClearStoreRegistryURLJSON(&error)
+ if let error = error { throw error }
+ return nil
+
case "getStoreExtensions":
let args = call.arguments as! [String: Any]
let forceRefresh = args["force_refresh"] as? Bool ?? false
@@ -969,6 +1000,15 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
+ // CUE Sheet Parsing
+ case "parseCueSheet":
+ let args = call.arguments as! [String: Any]
+ let cuePath = args["cue_path"] as! String
+ let audioDir = args["audio_dir"] as? String ?? ""
+ let response = GobackendParseCueSheet(cuePath, audioDir, &error)
+ if let error = error { throw error }
+ return response
+
default:
throw NSError(
domain: "SpotiFLAC",
diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart
index d423fac6..94e71f92 100644
--- a/lib/constants/app_info.dart
+++ b/lib/constants/app_info.dart
@@ -1,21 +1,26 @@
+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.7.2';
- static const String buildNumber = '105';
+ static const String version = '3.9.0';
+ static const String buildNumber = '115';
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 copyright = '© 2026 SpotiFLAC';
-
+
static const String mobileAuthor = 'zarzet';
static const String originalAuthor = 'afkarxyz';
-
+
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
static const String githubUrl = 'https://github.com/$githubRepo';
- static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
-
+ static const String originalGithubUrl =
+ 'https://github.com/afkarxyz/SpotiFLAC';
+
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
}
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 822982eb..2fd44b83 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -256,7 +256,7 @@ abstract class AppLocalizations {
/// **'Filename Format'**
String get downloadFilenameFormat;
- /// Setting for folder structure
+ /// Title of the folder organization picker bottom sheet
///
/// In en, this message translates to:
/// **'Folder Organization'**
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
/// **'Import'**
String get dialogImport;
+ /// Confirm button in Download All dialog
+ ///
+ /// In en, this message translates to:
+ /// **'Download'**
+ String get dialogDownload;
+
/// Dialog button - discard changes
///
/// In en, this message translates to:
@@ -1306,6 +1312,24 @@ abstract class AppLocalizations {
/// **'No tracks found'**
String get errorNoTracksFound;
+ /// Error title - URL not handled by any extension or service
+ ///
+ /// In en, this message translates to:
+ /// **'Link not recognized'**
+ String get errorUrlNotRecognized;
+
+ /// Error message - URL not recognized explanation
+ ///
+ /// In en, this message translates to:
+ /// **'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'**
+ String get errorUrlNotRecognizedMessage;
+
+ /// Error message - generic URL fetch failure
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to load content from this link. Please try again.'**
+ String get errorUrlFetchFailed;
+
/// Error - extension source not available
///
/// In en, this message translates to:
@@ -1438,6 +1462,18 @@ abstract class AppLocalizations {
/// **'No organization'**
String get folderOrganizationNone;
+ /// Folder option - playlist folders
+ ///
+ /// In en, this message translates to:
+ /// **'By Playlist'**
+ String get folderOrganizationByPlaylist;
+
+ /// Subtitle for playlist folder option
+ ///
+ /// In en, this message translates to:
+ /// **'Separate folder for each playlist'**
+ String get folderOrganizationByPlaylistSubtitle;
+
/// Folder option - artist folders
///
/// In en, this message translates to:
@@ -2206,10 +2242,88 @@ abstract class AppLocalizations {
/// **'Clear filters'**
String get storeClearFilters;
+ /// Store setup screen - heading when no repo is configured
+ ///
+ /// In en, this message translates to:
+ /// **'Add Extension Repository'**
+ String get storeAddRepoTitle;
+
+ /// Store setup screen - explanatory text
+ ///
+ /// In en, this message translates to:
+ /// **'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'**
+ String get storeAddRepoDescription;
+
+ /// Label for the repository URL input field
+ ///
+ /// In en, this message translates to:
+ /// **'Repository URL'**
+ String get storeRepoUrlLabel;
+
+ /// Hint/placeholder for the repository URL input field
+ ///
+ /// In en, this message translates to:
+ /// **'https://github.com/user/repo'**
+ String get storeRepoUrlHint;
+
+ /// Helper text below the repository URL input field
+ ///
+ /// In en, this message translates to:
+ /// **'e.g. https://github.com/user/extensions-repo'**
+ String get storeRepoUrlHelper;
+
+ /// Button to submit a new repository URL
+ ///
+ /// In en, this message translates to:
+ /// **'Add Repository'**
+ String get storeAddRepoButton;
+
+ /// Tooltip for the change-repository icon button in the app bar
+ ///
+ /// In en, this message translates to:
+ /// **'Change repository'**
+ String get storeChangeRepoTooltip;
+
+ /// Title of the change/remove repository dialog
+ ///
+ /// In en, this message translates to:
+ /// **'Extension Repository'**
+ String get storeRepoDialogTitle;
+
+ /// Label shown above the current repository URL in the dialog
+ ///
+ /// In en, this message translates to:
+ /// **'Current repository:'**
+ String get storeRepoDialogCurrent;
+
+ /// Label for the new repository URL field inside the dialog
+ ///
+ /// In en, this message translates to:
+ /// **'New Repository URL'**
+ String get storeNewRepoUrlLabel;
+
+ /// Error heading when the store cannot be loaded
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to load store'**
+ String get storeLoadError;
+
+ /// Message when store has no extensions
+ ///
+ /// In en, this message translates to:
+ /// **'No extensions available'**
+ String get storeEmptyNoExtensions;
+
+ /// Message when search/filter returns no results
+ ///
+ /// In en, this message translates to:
+ /// **'No extensions found'**
+ String get storeEmptyNoResults;
+
/// Default search provider option
///
/// In en, this message translates to:
- /// **'Default (Deezer/Spotify)'**
+ /// **'Default (Deezer)'**
String get extensionDefaultProvider;
/// Subtitle for default provider
@@ -2482,6 +2596,66 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle;
+ /// Quality option label for Tidal lossy 320kbps
+ ///
+ /// In en, this message translates to:
+ /// **'Lossy 320kbps'**
+ String get downloadLossy320;
+
+ /// Setting title to pick output format for Tidal lossy downloads
+ ///
+ /// In en, this message translates to:
+ /// **'Lossy Format'**
+ String get downloadLossyFormat;
+
+ /// Title of the Tidal lossy format picker bottom sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Lossy 320kbps Format'**
+ String get downloadLossy320Format;
+
+ /// Description in the Tidal lossy format picker
+ ///
+ /// In en, this message translates to:
+ /// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
+ String get downloadLossy320FormatDesc;
+
+ /// Tidal lossy format option - MP3 320kbps
+ ///
+ /// In en, this message translates to:
+ /// **'MP3 320kbps'**
+ String get downloadLossyMp3;
+
+ /// Subtitle for MP3 320kbps Tidal lossy option
+ ///
+ /// In en, this message translates to:
+ /// **'Best compatibility, ~10MB per track'**
+ String get downloadLossyMp3Subtitle;
+
+ /// Tidal lossy format option - Opus 256kbps
+ ///
+ /// In en, this message translates to:
+ /// **'Opus 256kbps'**
+ String get downloadLossyOpus256;
+
+ /// Subtitle for Opus 256kbps Tidal lossy option
+ ///
+ /// In en, this message translates to:
+ /// **'Best quality Opus, ~8MB per track'**
+ String get downloadLossyOpus256Subtitle;
+
+ /// Tidal lossy format option - Opus 128kbps
+ ///
+ /// In en, this message translates to:
+ /// **'Opus 128kbps'**
+ String get downloadLossyOpus128;
+
+ /// Subtitle for Opus 128kbps Tidal lossy option
+ ///
+ /// In en, this message translates to:
+ /// **'Smallest size, ~4MB per track'**
+ String get downloadLossyOpus128Subtitle;
+
/// Note about quality availability
///
/// In en, this message translates to:
@@ -2992,6 +3166,42 @@ abstract class AppLocalizations {
/// **'Show when searching for existing tracks'**
String get libraryShowDuplicateIndicatorSubtitle;
+ /// Setting for automatic library scanning
+ ///
+ /// In en, this message translates to:
+ /// **'Auto Scan'**
+ String get libraryAutoScan;
+
+ /// Subtitle for auto scan setting
+ ///
+ /// In en, this message translates to:
+ /// **'Automatically scan your library for new files'**
+ String get libraryAutoScanSubtitle;
+
+ /// Auto scan disabled
+ ///
+ /// In en, this message translates to:
+ /// **'Off'**
+ String get libraryAutoScanOff;
+
+ /// Auto scan when app opens
+ ///
+ /// In en, this message translates to:
+ /// **'Every app open'**
+ String get libraryAutoScanOnOpen;
+
+ /// Auto scan once per day
+ ///
+ /// In en, this message translates to:
+ /// **'Daily'**
+ String get libraryAutoScanDaily;
+
+ /// Auto scan once per week
+ ///
+ /// In en, this message translates to:
+ /// **'Weekly'**
+ String get libraryAutoScanWeekly;
+
/// Section header for library actions
///
/// In en, this message translates to:
@@ -3724,6 +3934,36 @@ abstract class AppLocalizations {
/// **'FFmpeg metadata embed failed'**
String get trackReEnrichFfmpegFailed;
+ /// Action/button label for queueing FLAC redownloads for local tracks
+ ///
+ /// In en, this message translates to:
+ /// **'Queue FLAC'**
+ String get queueFlacAction;
+
+ /// Confirmation dialog body before queueing FLAC redownloads for local tracks
+ ///
+ /// In en, this message translates to:
+ /// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
+ String queueFlacConfirmMessage(int count);
+
+ /// Snackbar while resolving remote matches for local FLAC redownloads
+ ///
+ /// In en, this message translates to:
+ /// **'Finding FLAC matches... ({current}/{total})'**
+ String queueFlacFindingProgress(int current, int total);
+
+ /// Snackbar when no safe FLAC redownload matches were found
+ ///
+ /// In en, this message translates to:
+ /// **'No reliable online matches found for the selection'**
+ String get queueFlacNoReliableMatches;
+
+ /// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
+ ///
+ /// In en, this message translates to:
+ /// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
+
/// Snackbar when save operation fails
///
/// In en, this message translates to:
@@ -3739,7 +3979,7 @@ abstract class AppLocalizations {
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
- /// **'Convert to MP3 or Opus'**
+ /// **'Convert to MP3, Opus, ALAC, or FLAC'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
@@ -3776,6 +4016,21 @@ abstract class AppLocalizations {
String bitrate,
);
+ /// Confirmation dialog message for lossless-to-lossless conversion
+ ///
+ /// In en, this message translates to:
+ /// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ );
+
+ /// Hint shown when converting between lossless formats
+ ///
+ /// In en, this message translates to:
+ /// **'Lossless conversion — no quality loss'**
+ String get trackConvertLosslessHint;
+
/// Snackbar while converting
///
/// In en, this message translates to:
@@ -3794,6 +4049,78 @@ abstract class AppLocalizations {
/// **'Conversion failed'**
String get trackConvertFailed;
+ /// Title for CUE split bottom sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Split CUE Sheet'**
+ String get cueSplitTitle;
+
+ /// Subtitle for CUE split menu item
+ ///
+ /// In en, this message translates to:
+ /// **'Split CUE+FLAC into individual tracks'**
+ String get cueSplitSubtitle;
+
+ /// Album name in CUE split sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Album: {album}'**
+ String cueSplitAlbum(String album);
+
+ /// Artist name in CUE split sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Artist: {artist}'**
+ String cueSplitArtist(String artist);
+
+ /// Number of tracks in CUE sheet
+ ///
+ /// In en, this message translates to:
+ /// **'{count} tracks'**
+ String cueSplitTrackCount(int count);
+
+ /// CUE split confirmation dialog title
+ ///
+ /// In en, this message translates to:
+ /// **'Split CUE Album'**
+ String get cueSplitConfirmTitle;
+
+ /// CUE split confirmation dialog message
+ ///
+ /// In en, this message translates to:
+ /// **'Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.'**
+ String cueSplitConfirmMessage(String album, int count);
+
+ /// Snackbar while splitting CUE
+ ///
+ /// In en, this message translates to:
+ /// **'Splitting CUE sheet... ({current}/{total})'**
+ String cueSplitSplitting(int current, int total);
+
+ /// Snackbar after successful CUE split
+ ///
+ /// In en, this message translates to:
+ /// **'Split into {count} tracks successfully'**
+ String cueSplitSuccess(int count);
+
+ /// Snackbar when CUE split fails
+ ///
+ /// In en, this message translates to:
+ /// **'CUE split failed'**
+ String get cueSplitFailed;
+
+ /// Error when CUE audio file is missing
+ ///
+ /// In en, this message translates to:
+ /// **'Audio file not found for this CUE sheet'**
+ String get cueSplitNoAudioFile;
+
+ /// Button text to start CUE splitting
+ ///
+ /// In en, this message translates to:
+ /// **'Split into Tracks'**
+ String get cueSplitButton;
+
/// Generic action button - create
///
/// In en, this message translates to:
@@ -4074,6 +4401,12 @@ abstract class AppLocalizations {
String bitrate,
);
+ /// Confirmation dialog message for lossless batch conversion
+ ///
+ /// In en, this message translates to:
+ /// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
+ String selectionBatchConvertConfirmMessageLossless(int count, String format);
+
/// Snackbar during batch conversion progress
///
/// In en, this message translates to:
@@ -4103,6 +4436,654 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Artist folders use Track Artist only'**
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
+
+ /// Title for the lyrics provider priority page
+ ///
+ /// In en, this message translates to:
+ /// **'Lyrics Providers'**
+ String get lyricsProvidersTitle;
+
+ /// Description on the lyrics provider priority page
+ ///
+ /// In en, this message translates to:
+ /// **'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'**
+ String get lyricsProvidersDescription;
+
+ /// Info tip on lyrics provider priority page
+ ///
+ /// In en, this message translates to:
+ /// **'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'**
+ String get lyricsProvidersInfoText;
+
+ /// Section header for enabled providers
+ ///
+ /// In en, this message translates to:
+ /// **'Enabled ({count})'**
+ String lyricsProvidersEnabledSection(int count);
+
+ /// Section header for disabled providers
+ ///
+ /// In en, this message translates to:
+ /// **'Disabled ({count})'**
+ String lyricsProvidersDisabledSection(int count);
+
+ /// Snackbar when user tries to disable the last enabled provider
+ ///
+ /// In en, this message translates to:
+ /// **'At least one provider must remain enabled'**
+ String get lyricsProvidersAtLeastOne;
+
+ /// Snackbar after saving lyrics provider priority
+ ///
+ /// In en, this message translates to:
+ /// **'Lyrics provider priority saved'**
+ String get lyricsProvidersSaved;
+
+ /// Body text of the discard-changes dialog on lyrics provider page
+ ///
+ /// In en, this message translates to:
+ /// **'You have unsaved changes that will be lost.'**
+ String get lyricsProvidersDiscardContent;
+
+ /// Description for Spotify Lyrics API provider
+ ///
+ /// In en, this message translates to:
+ /// **'Spotify-sourced synced lyrics via community API'**
+ String get lyricsProviderSpotifyApiDesc;
+
+ /// Description for LRCLIB provider
+ ///
+ /// In en, this message translates to:
+ /// **'Open-source synced lyrics database'**
+ String get lyricsProviderLrclibDesc;
+
+ /// Description for Netease provider
+ ///
+ /// In en, this message translates to:
+ /// **'NetEase Cloud Music (good for Asian songs)'**
+ String get lyricsProviderNeteaseDesc;
+
+ /// Description for Musixmatch provider
+ ///
+ /// In en, this message translates to:
+ /// **'Largest lyrics database (multi-language)'**
+ String get lyricsProviderMusixmatchDesc;
+
+ /// Description for Apple Music provider
+ ///
+ /// In en, this message translates to:
+ /// **'Word-by-word synced lyrics (via proxy)'**
+ String get lyricsProviderAppleMusicDesc;
+
+ /// Description for QQ Music provider
+ ///
+ /// In en, this message translates to:
+ /// **'QQ Music (good for Chinese songs, via proxy)'**
+ String get lyricsProviderQqMusicDesc;
+
+ /// Generic description for extension-based lyrics providers
+ ///
+ /// In en, this message translates to:
+ /// **'Extension provider'**
+ String get lyricsProviderExtensionDesc;
+
+ /// Title of SAF migration dialog
+ ///
+ /// In en, this message translates to:
+ /// **'Storage Update Required'**
+ String get safMigrationTitle;
+
+ /// First paragraph of SAF migration dialog
+ ///
+ /// In en, this message translates to:
+ /// **'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'**
+ String get safMigrationMessage1;
+
+ /// Second paragraph of SAF migration dialog
+ ///
+ /// In en, this message translates to:
+ /// **'Please select your download folder again to switch to the new storage system.'**
+ String get safMigrationMessage2;
+
+ /// Snackbar after successfully migrating to SAF
+ ///
+ /// In en, this message translates to:
+ /// **'Download folder updated to SAF mode'**
+ String get safMigrationSuccess;
+
+ /// Settings menu item - donate
+ ///
+ /// In en, this message translates to:
+ /// **'Donate'**
+ String get settingsDonate;
+
+ /// Subtitle for donate menu item
+ ///
+ /// In en, this message translates to:
+ /// **'Support SpotiFLAC-Mobile development'**
+ String get settingsDonateSubtitle;
+
+ /// Tooltip for the Love All button on album/playlist screens
+ ///
+ /// In en, this message translates to:
+ /// **'Love All'**
+ String get tooltipLoveAll;
+
+ /// Tooltip for the Add to Playlist button
+ ///
+ /// In en, this message translates to:
+ /// **'Add to Playlist'**
+ String get tooltipAddToPlaylist;
+
+ /// Snackbar after removing multiple tracks from Loved folder
+ ///
+ /// In en, this message translates to:
+ /// **'Removed {count} tracks from Loved'**
+ String snackbarRemovedTracksFromLoved(int count);
+
+ /// Snackbar after adding multiple tracks to Loved folder
+ ///
+ /// In en, this message translates to:
+ /// **'Added {count} tracks to Loved'**
+ String snackbarAddedTracksToLoved(int count);
+
+ /// Dialog title for bulk download confirmation
+ ///
+ /// In en, this message translates to:
+ /// **'Download All'**
+ String get dialogDownloadAllTitle;
+
+ /// Body of the Download All confirmation dialog
+ ///
+ /// In en, this message translates to:
+ /// **'Download {count} tracks?'**
+ String dialogDownloadAllMessage(int count);
+
+ /// Checkbox label in import dialog to skip already-downloaded songs
+ ///
+ /// In en, this message translates to:
+ /// **'Skip already downloaded songs'**
+ String get homeSkipAlreadyDownloaded;
+
+ /// Context menu item to navigate to the album page
+ ///
+ /// In en, this message translates to:
+ /// **'Go to Album'**
+ String get homeGoToAlbum;
+
+ /// Snackbar when album info cannot be loaded
+ ///
+ /// In en, this message translates to:
+ /// **'Album info not available'**
+ String get homeAlbumInfoUnavailable;
+
+ /// Snackbar while loading a CUE sheet file
+ ///
+ /// In en, this message translates to:
+ /// **'Loading CUE sheet...'**
+ String get snackbarLoadingCueSheet;
+
+ /// Snackbar after successfully saving track metadata
+ ///
+ /// In en, this message translates to:
+ /// **'Metadata saved successfully'**
+ String get snackbarMetadataSaved;
+
+ /// Snackbar when lyrics embedding fails
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to embed lyrics'**
+ String get snackbarFailedToEmbedLyrics;
+
+ /// Snackbar when writing metadata back to file fails
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to write back to storage'**
+ String get snackbarFailedToWriteStorage;
+
+ /// Generic error snackbar with error detail
+ ///
+ /// In en, this message translates to:
+ /// **'Error: {error}'**
+ String snackbarError(String error);
+
+ /// Snackbar when an extension button has no action configured
+ ///
+ /// In en, this message translates to:
+ /// **'No action defined for this button'**
+ String get snackbarNoActionDefined;
+
+ /// Empty state message when an album has no tracks
+ ///
+ /// In en, this message translates to:
+ /// **'No tracks found for this album'**
+ String get noTracksFoundForAlbum;
+
+ /// Subtitle text in Android download location bottom sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Choose storage mode for downloaded files.'**
+ String get downloadLocationSubtitle;
+
+ /// Storage mode option - use legacy app folder
+ ///
+ /// In en, this message translates to:
+ /// **'App folder (non-SAF)'**
+ String get storageModeAppFolder;
+
+ /// Subtitle for app folder storage mode
+ ///
+ /// In en, this message translates to:
+ /// **'Use default Music/SpotiFLAC path'**
+ String get storageModeAppFolderSubtitle;
+
+ /// Storage mode option - use Android SAF picker
+ ///
+ /// In en, this message translates to:
+ /// **'SAF folder'**
+ String get storageModeSaf;
+
+ /// Subtitle for SAF storage mode
+ ///
+ /// In en, this message translates to:
+ /// **'Pick folder via Android Storage Access Framework'**
+ String get storageModeSafSubtitle;
+
+ /// Description text in filename format bottom sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Customize how your files are named.'**
+ String get downloadFilenameDescription;
+
+ /// Label above filename tag chips
+ ///
+ /// In en, this message translates to:
+ /// **'Tap to insert tag:'**
+ String get downloadFilenameInsertTag;
+
+ /// Subtitle when separate singles folder is enabled
+ ///
+ /// In en, this message translates to:
+ /// **'Albums/ and Singles/ folders'**
+ String get downloadSeparateSinglesEnabled;
+
+ /// Subtitle when separate singles folder is disabled
+ ///
+ /// In en, this message translates to:
+ /// **'All files in same structure'**
+ String get downloadSeparateSinglesDisabled;
+
+ /// Setting title for artist folder filter options
+ ///
+ /// In en, this message translates to:
+ /// **'Artist Name Filters'**
+ String get downloadArtistNameFilters;
+
+ /// Setting title for adding a playlist folder prefix before the normal organization structure
+ ///
+ /// In en, this message translates to:
+ /// **'Create playlist source folder'**
+ String get downloadCreatePlaylistSourceFolder;
+
+ /// Subtitle when playlist source folder prefix is enabled
+ ///
+ /// In en, this message translates to:
+ /// **'Playlist downloads use Playlist/ plus your normal folder structure.'**
+ String get downloadCreatePlaylistSourceFolderEnabled;
+
+ /// Subtitle when playlist source folder prefix is disabled
+ ///
+ /// In en, this message translates to:
+ /// **'Playlist downloads use the normal folder structure only.'**
+ String get downloadCreatePlaylistSourceFolderDisabled;
+
+ /// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist
+ ///
+ /// In en, this message translates to:
+ /// **'By Playlist already places downloads inside a playlist folder.'**
+ String get downloadCreatePlaylistSourceFolderRedundant;
+
+ /// Setting title for SongLink country region
+ ///
+ /// In en, this message translates to:
+ /// **'SongLink Region'**
+ String get downloadSongLinkRegion;
+
+ /// Setting title for network compatibility toggle
+ ///
+ /// In en, this message translates to:
+ /// **'Network compatibility mode'**
+ String get downloadNetworkCompatibilityMode;
+
+ /// Subtitle when network compatibility mode is enabled
+ ///
+ /// In en, this message translates to:
+ /// **'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'**
+ String get downloadNetworkCompatibilityModeEnabled;
+
+ /// Subtitle when network compatibility mode is disabled
+ ///
+ /// In en, this message translates to:
+ /// **'Off: strict HTTPS certificate validation (recommended)'**
+ String get downloadNetworkCompatibilityModeDisabled;
+
+ /// Hint shown instead of Ask-quality subtitle when no built-in service selected
+ ///
+ /// In en, this message translates to:
+ /// **'Select a built-in service to enable'**
+ String get downloadSelectServiceToEnable;
+
+ /// Info hint when non-Tidal/Qobuz service is selected
+ ///
+ /// In en, this message translates to:
+ /// **'Select Tidal or Qobuz above to configure quality'**
+ String get downloadSelectTidalQobuz;
+
+ /// Subtitle for Embed Lyrics when Embed Metadata is disabled
+ ///
+ /// In en, this message translates to:
+ /// **'Disabled while Embed Metadata is turned off'**
+ String get downloadEmbedLyricsDisabled;
+
+ /// Toggle title for including Netease translated lyrics
+ ///
+ /// In en, this message translates to:
+ /// **'Netease: Include Translation'**
+ String get downloadNeteaseIncludeTranslation;
+
+ /// Subtitle when Netease translation is enabled
+ ///
+ /// In en, this message translates to:
+ /// **'Append translated lyrics when available'**
+ String get downloadNeteaseIncludeTranslationEnabled;
+
+ /// Subtitle when Netease translation is disabled
+ ///
+ /// In en, this message translates to:
+ /// **'Use original lyrics only'**
+ String get downloadNeteaseIncludeTranslationDisabled;
+
+ /// Toggle title for including Netease romanized lyrics
+ ///
+ /// In en, this message translates to:
+ /// **'Netease: Include Romanization'**
+ String get downloadNeteaseIncludeRomanization;
+
+ /// Subtitle when Netease romanization is enabled
+ ///
+ /// In en, this message translates to:
+ /// **'Append romanized lyrics when available'**
+ String get downloadNeteaseIncludeRomanizationEnabled;
+
+ /// Subtitle when Netease romanization is disabled
+ ///
+ /// In en, this message translates to:
+ /// **'Disabled'**
+ String get downloadNeteaseIncludeRomanizationDisabled;
+
+ /// Toggle title for Apple/QQ multi-person word-by-word lyrics
+ ///
+ /// In en, this message translates to:
+ /// **'Apple/QQ Multi-Person Word-by-Word'**
+ String get downloadAppleQqMultiPerson;
+
+ /// Subtitle when multi-person word-by-word is enabled
+ ///
+ /// In en, this message translates to:
+ /// **'Enable v1/v2 speaker and [bg:] tags'**
+ String get downloadAppleQqMultiPersonEnabled;
+
+ /// Subtitle when multi-person word-by-word is disabled
+ ///
+ /// In en, this message translates to:
+ /// **'Simplified word-by-word formatting'**
+ String get downloadAppleQqMultiPersonDisabled;
+
+ /// Setting title for Musixmatch language preference
+ ///
+ /// In en, this message translates to:
+ /// **'Musixmatch Language'**
+ String get downloadMusixmatchLanguage;
+
+ /// Option label when Musixmatch uses original language
+ ///
+ /// In en, this message translates to:
+ /// **'Auto (original)'**
+ String get downloadMusixmatchLanguageAuto;
+
+ /// Toggle title for filtering contributing artists in Album Artist metadata
+ ///
+ /// In en, this message translates to:
+ /// **'Filter contributing artists in Album Artist'**
+ String get downloadFilterContributing;
+
+ /// Subtitle when contributing artist filter is enabled
+ ///
+ /// In en, this message translates to:
+ /// **'Album Artist metadata uses primary artist only'**
+ String get downloadFilterContributingEnabled;
+
+ /// Subtitle when contributing artist filter is disabled
+ ///
+ /// In en, this message translates to:
+ /// **'Keep full Album Artist metadata value'**
+ String get downloadFilterContributingDisabled;
+
+ /// Subtitle for lyrics providers setting when no providers are enabled
+ ///
+ /// In en, this message translates to:
+ /// **'None enabled'**
+ String get downloadProvidersNoneEnabled;
+
+ /// Label for the Musixmatch language code text field
+ ///
+ /// In en, this message translates to:
+ /// **'Language code'**
+ String get downloadMusixmatchLanguageCode;
+
+ /// Hint text for the Musixmatch language code field
+ ///
+ /// In en, this message translates to:
+ /// **'auto / en / es / ja'**
+ String get downloadMusixmatchLanguageHint;
+
+ /// Description in the Musixmatch language picker
+ ///
+ /// In en, this message translates to:
+ /// **'Set preferred language code (example: en, es, ja). Leave empty for auto.'**
+ String get downloadMusixmatchLanguageDesc;
+
+ /// Button to reset Musixmatch language to automatic
+ ///
+ /// In en, this message translates to:
+ /// **'Auto'**
+ String get downloadMusixmatchAuto;
+
+ /// Subtitle for 'Any' network mode option
+ ///
+ /// In en, this message translates to:
+ /// **'WiFi + Mobile Data'**
+ String get downloadNetworkAnySubtitle;
+
+ /// Subtitle for 'WiFi only' network mode option
+ ///
+ /// In en, this message translates to:
+ /// **'Pause downloads on mobile data'**
+ String get downloadNetworkWifiOnlySubtitle;
+
+ /// Description in the SongLink region picker
+ ///
+ /// In en, this message translates to:
+ /// **'Used as userCountry for SongLink API lookup.'**
+ String get downloadSongLinkRegionDesc;
+
+ /// Snackbar when the audio format is not supported for the requested operation
+ ///
+ /// In en, this message translates to:
+ /// **'Unsupported audio format'**
+ String get snackbarUnsupportedAudioFormat;
+
+ /// Tooltip for refresh button on cache management page
+ ///
+ /// In en, this message translates to:
+ /// **'Refresh'**
+ String get cacheRefresh;
+
+ /// Dialog message for bulk playlist download confirmation
+ ///
+ /// In en, this message translates to:
+ /// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
+
+ /// Button label for bulk downloading selected playlists
+ ///
+ /// In en, this message translates to:
+ /// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
+ String bulkDownloadPlaylistsButton(int count);
+
+ /// Button label when no playlists are selected for download
+ ///
+ /// In en, this message translates to:
+ /// **'Select playlists to download'**
+ String get bulkDownloadSelectPlaylists;
+
+ /// Snackbar when selected playlists contain no tracks
+ ///
+ /// In en, this message translates to:
+ /// **'Selected playlists have no tracks'**
+ String get snackbarSelectedPlaylistsEmpty;
+
+ /// Playlist count display
+ ///
+ /// In en, this message translates to:
+ /// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
+ String playlistsCount(int count);
+
+ /// Section title for selective online metadata auto-fill in the edit metadata sheet
+ ///
+ /// In en, this message translates to:
+ /// **'Auto-fill from online'**
+ String get editMetadataAutoFill;
+
+ /// Description for the auto-fill section
+ ///
+ /// In en, this message translates to:
+ /// **'Select fields to fill automatically from online metadata'**
+ String get editMetadataAutoFillDesc;
+
+ /// Button label to fetch online metadata and fill selected fields
+ ///
+ /// In en, this message translates to:
+ /// **'Fetch & Fill'**
+ String get editMetadataAutoFillFetch;
+
+ /// Snackbar shown while searching for online metadata
+ ///
+ /// In en, this message translates to:
+ /// **'Searching online...'**
+ String get editMetadataAutoFillSearching;
+
+ /// Snackbar when online metadata search returns no results
+ ///
+ /// In en, this message translates to:
+ /// **'No matching metadata found online'**
+ String get editMetadataAutoFillNoResults;
+
+ /// Snackbar confirming how many fields were auto-filled
+ ///
+ /// In en, this message translates to:
+ /// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
+ String editMetadataAutoFillDone(int count);
+
+ /// Snackbar when user taps Fetch without selecting any fields
+ ///
+ /// In en, this message translates to:
+ /// **'Select at least one field to auto-fill'**
+ String get editMetadataAutoFillNoneSelected;
+
+ /// Chip label for title field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Title'**
+ String get editMetadataFieldTitle;
+
+ /// Chip label for artist field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Artist'**
+ String get editMetadataFieldArtist;
+
+ /// Chip label for album field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Album'**
+ String get editMetadataFieldAlbum;
+
+ /// Chip label for album artist field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Album Artist'**
+ String get editMetadataFieldAlbumArtist;
+
+ /// Chip label for date field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Date'**
+ String get editMetadataFieldDate;
+
+ /// Chip label for track number field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Track #'**
+ String get editMetadataFieldTrackNum;
+
+ /// Chip label for disc number field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Disc #'**
+ String get editMetadataFieldDiscNum;
+
+ /// Chip label for genre field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Genre'**
+ String get editMetadataFieldGenre;
+
+ /// Chip label for ISRC field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'ISRC'**
+ String get editMetadataFieldIsrc;
+
+ /// Chip label for label field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Label'**
+ String get editMetadataFieldLabel;
+
+ /// Chip label for copyright field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Copyright'**
+ String get editMetadataFieldCopyright;
+
+ /// Chip label for cover art field in auto-fill selector
+ ///
+ /// In en, this message translates to:
+ /// **'Cover Art'**
+ String get editMetadataFieldCover;
+
+ /// Button to select all fields for auto-fill
+ ///
+ /// In en, this message translates to:
+ /// **'All'**
+ String get editMetadataSelectAll;
+
+ /// Button to select only fields that are currently empty
+ ///
+ /// In en, this message translates to:
+ /// **'Empty only'**
+ String get editMetadataSelectEmpty;
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart
index af8f7b73..7d2e6008 100644
--- a/lib/l10n/app_localizations_de.dart
+++ b/lib/l10n/app_localizations_de.dart
@@ -15,7 +15,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get navHome => 'Startseite';
@override
- String get navLibrary => 'Archiv';
+ String get navLibrary => 'Bibliothek';
@override
String get navSettings => 'Einstellungen';
@@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutAppDescription =>
- 'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
+ 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
@override
String get artistAlbums => 'Alben';
@@ -500,7 +500,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get setupNotificationBackgroundDescription =>
- 'Werde benachrichtigt über Download-Fortschritt und -Fertigstellung. Dies hilft Ihnen, Downloads zu verfolgen, wenn die App im Hintergrund ist.';
+ 'Erhalte Benachrichtigungen über den Fortschritt und die Fertigstellung deiner Downloads, selbst wenn die App im Hintergrund läuft.';
@override
String get setupSkipForNow => 'Vorerst überspringen';
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dialogImport => 'Importieren';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Verwerfen';
@@ -592,11 +595,11 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
- String get dialogImportPlaylistTitle => 'Wiedergabeliste importieren';
+ String get dialogImportPlaylistTitle => 'Playlist importieren';
@override
String dialogImportPlaylistMessage(int count) {
- return '$count Titel in CSV gefunden. Zur Warteschlange hinzufügen?';
+ return '$count Titel gefunden hinzufügen?';
}
@override
@@ -606,12 +609,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String snackbarAddedToQueue(String trackName) {
- return '\"$trackName\" zur Warteschlange hinzugefügt';
+ return '\"$trackName\" hinzugefügt';
}
@override
String snackbarAddedTracksToQueue(int count) {
- return '$count Titel zur Warteschlange hinzugefügt';
+ return '$count Titel hinzugefügt';
}
@override
@@ -701,6 +704,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get errorNoTracksFound => 'Keine Titel gefunden';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Kann $item nicht lade wegen fehlender Erweiterungsquelle';
@@ -765,15 +779,22 @@ class AppLocalizationsDe extends AppLocalizations {
String get filenameFormat => 'Dateinamenformat';
@override
- String get filenameShowAdvancedTags => 'Show advanced tags';
+ String get filenameShowAdvancedTags => 'Erweiterte Tags anzeigen';
@override
String get filenameShowAdvancedTagsDescription =>
- 'Enable formatted tags for track padding and date patterns';
+ 'Formatierte Tags für Track-Padding und Datumsmuster aktivieren';
@override
String get folderOrganizationNone => 'Keine Organisation';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'Nach Künstler';
@@ -918,11 +939,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String logEntries(int count) {
- return 'Entries ($count)';
+ return '$count Einträge';
}
@override
- String get credentialsTitle => 'Spotify Credentials';
+ String get credentialsTitle => 'Spotify-Anmeldedaten';
@override
String get credentialsDescription =>
@@ -984,7 +1005,7 @@ class AppLocalizationsDe extends AppLocalizations {
'Wähle wie Songtexte mit deinen Downloads gespeichert werden';
@override
- String get lyricsModeEmbed => 'In Datei einbinden';
+ String get lyricsModeEmbed => 'In Datei einbetten';
@override
String get lyricsModeEmbedSubtitle => 'Lyrics in FLAC Metadaten gespeichert';
@@ -1001,7 +1022,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get lyricsModeBothSubtitle =>
- 'Lyrics einbinden und als .lrc speichern';
+ 'Lyrics einbetten und als .lrc speichern';
@override
String get sectionColor => 'Farbe';
@@ -1019,29 +1040,30 @@ class AppLocalizationsDe extends AppLocalizations {
String get appearanceLanguage => 'App Sprache';
@override
- String get settingsAppearanceSubtitle => 'Theme, colors, display';
+ String get settingsAppearanceSubtitle => 'Design, Farben, Anzeige';
@override
- String get settingsDownloadSubtitle => 'Service, quality, filename format';
+ String get settingsDownloadSubtitle => 'Dienst, Qualität, Dateinamen-Format';
@override
- String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
+ String get settingsOptionsSubtitle => 'Fallback, Lyrics, Covers, Updates';
@override
- String get settingsExtensionsSubtitle => 'Manage download providers';
+ String get settingsExtensionsSubtitle => 'Download-Anbieter verwalten';
@override
- String get settingsLogsSubtitle => 'View app logs for debugging';
+ String get settingsLogsSubtitle => 'App-Logs zum Debuggen anzeigen';
@override
- String get loadingSharedLink => 'Loading shared link...';
+ String get loadingSharedLink => 'Link wird geladen...';
@override
- String get pressBackAgainToExit => 'Press back again to exit';
+ String get pressBackAgainToExit =>
+ 'Drücke wieder \"zurück\" um die App zu beenden';
@override
String downloadAllCount(int count) {
- return 'Download All ($count)';
+ return 'Alle $count Titel herunterladen';
}
@override
@@ -1049,65 +1071,65 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: '$count tracks',
- one: '1 track',
+ other: '$count Titel',
+ one: '1 Titel',
);
return '$_temp0';
}
@override
- String get trackCopyFilePath => 'Copy file path';
+ String get trackCopyFilePath => 'Dateipfad kopieren';
@override
- String get trackRemoveFromDevice => 'Remove from device';
+ String get trackRemoveFromDevice => 'Vom Gerät entfernen';
@override
- String get trackLoadLyrics => 'Load Lyrics';
+ String get trackLoadLyrics => 'Lade Lyrics';
@override
- String get trackMetadata => 'Metadata';
+ String get trackMetadata => 'Metadaten';
@override
- String get trackFileInfo => 'File Info';
+ String get trackFileInfo => 'Datei-Info';
@override
String get trackLyrics => 'Lyrics';
@override
- String get trackFileNotFound => 'File not found';
+ String get trackFileNotFound => 'Datei nicht gefunden';
@override
- String get trackOpenInDeezer => 'Open in Deezer';
+ String get trackOpenInDeezer => 'In Deezer öffnen';
@override
- String get trackOpenInSpotify => 'Open in Spotify';
+ String get trackOpenInSpotify => 'In Spotify öffnen';
@override
- String get trackTrackName => 'Track name';
+ String get trackTrackName => 'Name des Titels';
@override
- String get trackArtist => 'Artist';
+ String get trackArtist => 'Künstler';
@override
- String get trackAlbumArtist => 'Album artist';
+ String get trackAlbumArtist => 'Album Künstler';
@override
String get trackAlbum => 'Album';
@override
- String get trackTrackNumber => 'Track number';
+ String get trackTrackNumber => 'Titelnummer';
@override
- String get trackDiscNumber => 'Disc number';
+ String get trackDiscNumber => 'CD-Nummer';
@override
- String get trackDuration => 'Duration';
+ String get trackDuration => 'Länge';
@override
- String get trackAudioQuality => 'Audio quality';
+ String get trackAudioQuality => 'Audioqualität';
@override
- String get trackReleaseDate => 'Release date';
+ String get trackReleaseDate => 'Erscheinungsdatum';
@override
String get trackGenre => 'Genre';
@@ -1116,71 +1138,73 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackLabel => 'Label';
@override
- String get trackCopyright => 'Copyright';
+ String get trackCopyright => 'Urheberrecht';
@override
- String get trackDownloaded => 'Downloaded';
+ String get trackDownloaded => 'Heruntergeladen';
@override
- String get trackCopyLyrics => 'Copy lyrics';
+ String get trackCopyLyrics => 'Lyrics kopieren';
@override
- String get trackLyricsNotAvailable => 'Lyrics not available for this track';
+ String get trackLyricsNotAvailable =>
+ 'Lyrics sind für diesen Titel nicht verfügbar';
@override
- String get trackLyricsTimeout => 'Request timed out. Try again later.';
+ String get trackLyricsTimeout =>
+ 'Anfrage Timeout. Versuche es später erneut.';
@override
- String get trackLyricsLoadFailed => 'Failed to load lyrics';
+ String get trackLyricsLoadFailed => 'Fehler beim Laden der Lyrics';
@override
- String get trackEmbedLyrics => 'Embed Lyrics';
+ String get trackEmbedLyrics => 'Lyrics einbetten';
@override
- String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+ String get trackLyricsEmbedded => 'Lyrics erfolgreich eingebettet';
@override
- String get trackInstrumental => 'Instrumental track';
+ String get trackInstrumental => 'Instrumentalspur';
@override
- String get trackCopiedToClipboard => 'Copied to clipboard';
+ String get trackCopiedToClipboard => 'In Zwischenablage kopiert';
@override
- String get trackDeleteConfirmTitle => 'Remove from device?';
+ String get trackDeleteConfirmTitle => 'Vom Gerät entfernen?';
@override
String get trackDeleteConfirmMessage =>
- 'This will permanently delete the downloaded file and remove it from your history.';
+ 'Dies wird die heruntergeladene Datei dauerhaft löschen und sie aus deinem Verlauf entfernen.';
@override
- String get dateToday => 'Today';
+ String get dateToday => 'Heute';
@override
- String get dateYesterday => 'Yesterday';
+ String get dateYesterday => 'Gestern';
@override
String dateDaysAgo(int count) {
- return '$count days ago';
+ return 'Vor $count Tagen';
}
@override
String dateWeeksAgo(int count) {
- return '$count weeks ago';
+ return 'Vor $count Wochen';
}
@override
String dateMonthsAgo(int count) {
- return '$count months ago';
+ return 'Vor $count Monaten';
}
@override
- String get storeFilterAll => 'All';
+ String get storeFilterAll => 'Alle';
@override
- String get storeFilterMetadata => 'Metadata';
+ String get storeFilterMetadata => 'Metadaten';
@override
- String get storeFilterDownload => 'Download';
+ String get storeFilterDownload => 'Herunterladen';
@override
String get storeFilterUtility => 'Utility';
@@ -1192,142 +1216,187 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeFilterIntegration => 'Integration';
@override
- String get storeClearFilters => 'Clear filters';
+ String get storeClearFilters => 'Filter entfernen';
@override
- String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
+ String get storeAddRepoTitle => 'Add Extension Repository';
@override
- String get extensionDefaultProviderSubtitle => 'Use built-in search';
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
@override
- String get extensionAuthor => 'Author';
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
+ @override
+ String get extensionDefaultProvider => 'Standard (Deezer/Spotify)';
+
+ @override
+ String get extensionDefaultProviderSubtitle => 'Eingebaute Suche verwenden';
+
+ @override
+ String get extensionAuthor => 'Entwickler';
@override
String get extensionId => 'ID';
@override
- String get extensionError => 'Error';
+ String get extensionError => 'Fehler';
@override
- String get extensionCapabilities => 'Capabilities';
+ String get extensionCapabilities => 'Eigenschaften';
@override
- String get extensionMetadataProvider => 'Metadata Provider';
+ String get extensionMetadataProvider => 'Metadaten-Anbieter';
@override
- String get extensionDownloadProvider => 'Download Provider';
+ String get extensionDownloadProvider => 'Download-Anbieter';
@override
- String get extensionLyricsProvider => 'Lyrics Provider';
+ String get extensionLyricsProvider => 'Lyrics-Anbieter';
@override
String get extensionUrlHandler => 'URL Handler';
@override
- String get extensionQualityOptions => 'Quality Options';
+ String get extensionQualityOptions => 'Qualitätsoptionen';
@override
String get extensionPostProcessingHooks => 'Post-Processing Hooks';
@override
- String get extensionPermissions => 'Permissions';
+ String get extensionPermissions => 'Berechtigungen';
@override
- String get extensionSettings => 'Settings';
+ String get extensionSettings => 'Einstellungen';
@override
- String get extensionRemoveButton => 'Remove Extension';
+ String get extensionRemoveButton => 'Erweiterung entfernen';
@override
- String get extensionUpdated => 'Updated';
+ String get extensionUpdated => 'Aktualisiert';
@override
- String get extensionMinAppVersion => 'Min App Version';
+ String get extensionMinAppVersion => 'Min App-Version';
@override
- String get extensionCustomTrackMatching => 'Custom Track Matching';
+ String get extensionCustomTrackMatching =>
+ 'Benutzerdefiniertes Track-Matching';
@override
- String get extensionPostProcessing => 'Post-Processing';
+ String get extensionPostProcessing => 'Post-processing';
@override
String extensionHooksAvailable(int count) {
- return '$count hook(s) available';
+ return '$count Hook(s) verfügbar';
}
@override
String extensionPatternsCount(int count) {
- return '$count pattern(s)';
+ return '$count Muster';
}
@override
String extensionStrategy(String strategy) {
- return 'Strategy: $strategy';
+ return 'Strategie: $strategy';
}
@override
- String get extensionsProviderPrioritySection => 'Provider Priority';
+ String get extensionsProviderPrioritySection => 'Provider-Priorität';
@override
- String get extensionsInstalledSection => 'Installed Extensions';
+ String get extensionsInstalledSection => 'Installierte Erweiterungen';
@override
- String get extensionsNoExtensions => 'No extensions installed';
+ String get extensionsNoExtensions => 'Keine Erweiterungen installiert';
@override
String get extensionsNoExtensionsSubtitle =>
- 'Install .spotiflac-ext files to add new providers';
+ 'Installiere .spotiflac-ext Dateien um neue Anbieter hinzuzufügen';
@override
- String get extensionsInstallButton => 'Install Extension';
+ String get extensionsInstallButton => 'Erweiterung installieren';
@override
String get extensionsInfoTip =>
- 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.';
+ 'Erweiterungen können neue Metadaten und Download-Anbieter hinzufügen. Installiere nur Erweiterungen von vertrauenswürdigen Quellen.';
@override
- String get extensionsInstalledSuccess => 'Extension installed successfully';
+ String get extensionsInstalledSuccess =>
+ 'Erweiterung erfolgreich installiert';
@override
- String get extensionsDownloadPriority => 'Download Priority';
+ String get extensionsDownloadPriority => 'Download-Priorität';
@override
- String get extensionsDownloadPrioritySubtitle => 'Set download service order';
+ String get extensionsDownloadPrioritySubtitle =>
+ 'Download-Service-Reihenfolge festlegen';
@override
String get extensionsNoDownloadProvider =>
- 'No extensions with download provider';
+ 'Keine Erweiterungen mit Download-Provider';
@override
- String get extensionsMetadataPriority => 'Metadata Priority';
+ String get extensionsMetadataPriority => 'Metadaten Priorität';
@override
String get extensionsMetadataPrioritySubtitle =>
- 'Set search & metadata source order';
+ 'Reihenfolge der Such- und Metadaten quellen festlegen';
@override
String get extensionsNoMetadataProvider =>
- 'No extensions with metadata provider';
+ 'Keine Erweiterungen mit Metadaten-Anbieter';
@override
- String get extensionsSearchProvider => 'Search Provider';
+ String get extensionsSearchProvider => 'Such-Provider';
@override
- String get extensionsNoCustomSearch => 'No extensions with custom search';
+ String get extensionsNoCustomSearch =>
+ 'Keine Erweiterungen mit benutzerdefinierter Suche';
@override
String get extensionsSearchProviderDescription =>
- 'Choose which service to use for searching tracks';
+ 'Wähle den Dienst für die Suche von Titel';
@override
- String get extensionsCustomSearch => 'Custom search';
+ String get extensionsCustomSearch => 'Benutzerdefinierte Suche';
@override
- String get extensionsErrorLoading => 'Error loading extension';
+ String get extensionsErrorLoading => 'Fehler beim Laden der Erweiterung';
@override
- String get qualityFlacLossless => 'FLAC Lossless';
+ String get qualityFlacLossless => 'FLAC Verlustfrei';
@override
String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz';
@@ -1336,21 +1405,53 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityHiResFlac => 'Hi-Res FLAC';
@override
- String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz';
+ String get qualityHiResFlacSubtitle => '24-Bit / bis 96kHz';
@override
String get qualityHiResFlacMax => 'Hi-Res FLAC Max';
@override
- String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
+
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override
String get qualityNote =>
- 'Actual quality depends on track availability from the service';
+ 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
@override
String get youtubeQualityNote =>
- 'YouTube provides lossy audio only. Not part of lossless fallback.';
+ 'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@@ -1359,13 +1460,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
- String get downloadAskBeforeDownload => 'Ask Before Download';
+ String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
@override
- String get downloadDirectory => 'Download Directory';
+ String get downloadDirectory => 'Downloadverzeichnis';
@override
- String get downloadSeparateSinglesFolder => 'Separate Singles Folder';
+ String get downloadSeparateSinglesFolder => 'Singles Ordner trennen';
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@@ -1378,53 +1479,53 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
- 'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
+ 'Vorgestellte Künstler aus dem Ordnernamen entfernt (z.B. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
- String get downloadSelectQuality => 'Select Quality';
+ String get downloadSelectQuality => 'Qualität wählen';
@override
- String get downloadFrom => 'Download From';
+ String get downloadFrom => 'Herunterladen von';
@override
- String get appearanceAmoledDark => 'AMOLED Dark';
+ String get appearanceAmoledDark => 'AMOLED Schwarz';
@override
- String get appearanceAmoledDarkSubtitle => 'Pure black background';
+ String get appearanceAmoledDarkSubtitle => 'AMOLED Hintergrund';
@override
- String get queueClearAll => 'Clear All';
+ String get queueClearAll => 'Alles löschen';
@override
String get queueClearAllMessage =>
- 'Are you sure you want to clear all downloads?';
+ 'Bist du dir sicher, dass du alle Downloads löschen möchten?';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
- 'Save failed downloads to TXT file automatically';
+ 'Fehlgeschlagene Downloads automatisch in eine TXT-Datei speichern';
@override
- String get settingsDownloadNetwork => 'Download Network';
+ String get settingsDownloadNetwork => 'Download Netzwerk';
@override
- String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
+ String get settingsDownloadNetworkAny => 'WLAN + Mobile Daten';
@override
- String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
+ String get settingsDownloadNetworkWifiOnly => 'Nur WLAN';
@override
String get settingsDownloadNetworkSubtitle =>
- 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
+ 'Wähle aus, welches Netzwerk für Downloads verwendet werden soll. Wenn nur WLAN aktiviert wird, werden Downloads auf mobilen Daten angehalten.';
@override
- String get albumFolderArtistAlbum => 'Artist / Album';
+ String get albumFolderArtistAlbum => 'Künstler/Album';
@override
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
@@ -1434,13 +1535,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get albumFolderArtistYearAlbumSubtitle =>
- 'Albums/Artist Name/[2005] Album Name/';
+ 'Albums/Künster Name/[2005] Album Name/';
@override
- String get albumFolderAlbumOnly => 'Album Only';
+ String get albumFolderAlbumOnly => 'Nur Alben';
@override
- String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/';
+ String get albumFolderAlbumOnlySubtitle => 'Alben/Album Name/';
@override
String get albumFolderYearAlbum => '[Year] Album';
@@ -1456,39 +1557,39 @@ class AppLocalizationsDe extends AppLocalizations {
'Artist/Album/ and Artist/Singles/';
@override
- String get downloadedAlbumDeleteSelected => 'Delete Selected';
+ String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
@override
String downloadedAlbumDeleteMessage(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'tracks',
- one: 'track',
+ other: 'Titel',
+ one: 'Titel',
);
- return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.';
+ return '$count $_temp0 aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.';
}
@override
String downloadedAlbumSelectedCount(int count) {
- return '$count selected';
+ return '$count ausgewählt';
}
@override
- String get downloadedAlbumAllSelected => 'All tracks selected';
+ String get downloadedAlbumAllSelected => 'Alle Titel sind ausgewählt';
@override
- String get downloadedAlbumTapToSelect => 'Tap tracks to select';
+ String get downloadedAlbumTapToSelect => 'Tippe auf Titel zum Auswählen';
@override
String downloadedAlbumDeleteCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'tracks',
- one: 'track',
+ other: 'Titel',
+ one: 'Titel',
);
- return 'Delete $count $_temp0';
+ return 'Lösche $count $_temp0';
}
@override
@@ -1500,22 +1601,22 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
- String get recentTypeArtist => 'Artist';
+ String get recentTypeArtist => 'Künstler';
@override
String get recentTypeAlbum => 'Album';
@override
- String get recentTypeSong => 'Song';
+ String get recentTypeSong => 'Titel';
@override
String get recentTypePlaylist => 'Playlist';
@override
- String get recentEmpty => 'No recent items yet';
+ String get recentEmpty => 'Noch keine aktuellen Einträge';
@override
- String get recentShowAllDownloads => 'Show All Downloads';
+ String get recentShowAllDownloads => 'Alle Downloads anzeigen';
@override
String recentPlaylistInfo(String name) {
@@ -1523,41 +1624,41 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
- String get discographyDownload => 'Download Discography';
+ String get discographyDownload => 'Diskographie herunterladen';
@override
- String get discographyDownloadAll => 'Download All';
+ String get discographyDownloadAll => 'Alle Herunterladen';
@override
String discographyDownloadAllSubtitle(int count, int albumCount) {
- return '$count tracks from $albumCount releases';
+ return '$count Titel von $albumCount Releases';
}
@override
- String get discographyAlbumsOnly => 'Albums Only';
+ String get discographyAlbumsOnly => 'Nur Alben';
@override
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
- return '$count tracks from $albumCount albums';
+ return '$count Titel von $albumCount Albums';
}
@override
- String get discographySinglesOnly => 'Singles & EPs Only';
+ String get discographySinglesOnly => 'Nur Singles & EPs';
@override
String discographySinglesOnlySubtitle(int count, int albumCount) {
- return '$count tracks from $albumCount singles';
+ return '$count Titel von $albumCount Singles';
}
@override
- String get discographySelectAlbums => 'Select Albums...';
+ String get discographySelectAlbums => 'Alben auswählen...';
@override
String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles';
@override
- String get discographyFetchingTracks => 'Fetching tracks...';
+ String get discographyFetchingTracks => 'Lade Titel...';
@override
String discographyFetchingAlbum(int current, int total) {
@@ -1566,11 +1667,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String discographySelectedCount(int count) {
- return '$count selected';
+ return '$count ausgewählt';
}
@override
- String get discographyDownloadSelected => 'Download Selected';
+ String get discographyDownloadSelected => 'Auswahl herunterladen';
@override
String discographyAddedToQueue(int count) {
@@ -1579,20 +1680,20 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String discographySkippedDownloaded(int added, int skipped) {
- return '$added added, $skipped already downloaded';
+ return '$added hinzugefügt, $skipped bereits heruntergeladen';
}
@override
- String get discographyNoAlbums => 'No albums available';
+ String get discographyNoAlbums => 'Es sind keine Alben verfügbar';
@override
String get discographyFailedToFetch => 'Failed to fetch some albums';
@override
- String get sectionStorageAccess => 'Storage Access';
+ String get sectionStorageAccess => 'Speicherzugriff';
@override
- String get allFilesAccess => 'All Files Access';
+ String get allFilesAccess => 'Zugriff auf alle Dateien';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@@ -1602,170 +1703,189 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get allFilesAccessDescription =>
- 'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
+ 'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.';
@override
String get allFilesAccessDeniedMessage =>
- 'Permission was denied. Please enable \'All files access\' manually in system settings.';
+ 'Zugriff verweigert. Bitte aktiviere \"Zugriff auf alle Dateien\" manuell in den Systemeinstellungen.';
@override
String get allFilesAccessDisabledMessage =>
- 'All Files Access disabled. The app will use limited storage access.';
+ 'Zugriff auf alle Dateien ist deaktiviert. Die App verwendet nur begrenzten Zugriff auf den Speicher.';
@override
- String get settingsLocalLibrary => 'Local Library';
+ String get settingsLocalLibrary => 'Lokale Bibliothek';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
- String get settingsCache => 'Storage & Cache';
+ String get settingsCache => 'Speicher & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
- String get libraryTitle => 'Local Library';
+ String get libraryTitle => 'Lokale Bibliothek';
@override
- String get libraryScanSettings => 'Scan Settings';
+ String get libraryScanSettings => 'Scan Einstellungen';
@override
- String get libraryEnableLocalLibrary => 'Enable Local Library';
+ String get libraryEnableLocalLibrary => 'Lokale Bibliothek aktivieren';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
- String get libraryFolder => 'Library Folder';
+ String get libraryFolder => 'Bibliotheksordner';
@override
- String get libraryFolderHint => 'Tap to select folder';
+ String get libraryFolderHint => 'Tippe um Ordner auszuwählen';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@override
String get libraryShowDuplicateIndicatorSubtitle =>
- 'Show when searching for existing tracks';
+ 'Bei der Suche nach vorhandenen Titeln anzeigen';
@override
- String get libraryActions => 'Actions';
+ String get libraryAutoScan => 'Auto Scan';
@override
- String get libraryScan => 'Scan Library';
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
@override
- String get libraryScanSubtitle => 'Scan for audio files';
+ String get libraryAutoScanOff => 'Off';
@override
- String get libraryScanSelectFolderFirst => 'Select a folder first';
+ String get libraryAutoScanOnOpen => 'Every app open';
@override
- String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
+ @override
+ String get libraryActions => 'Aktionen';
+
+ @override
+ String get libraryScan => 'Bibliothek scannen';
+
+ @override
+ String get libraryScanSubtitle => 'Suche nach Audiodateien';
+
+ @override
+ String get libraryScanSelectFolderFirst => 'Wähle zuerst einen Ordner';
+
+ @override
+ String get libraryCleanupMissingFiles => 'Fehlende Dateien bereinigen';
@override
String get libraryCleanupMissingFilesSubtitle =>
- 'Remove entries for files that no longer exist';
+ 'Verlaufseinträge für Dateien löschen, die nicht mehr existieren';
@override
- String get libraryClear => 'Clear Library';
+ String get libraryClear => 'Bibliothek löschen';
@override
- String get libraryClearSubtitle => 'Remove all scanned tracks';
+ String get libraryClearSubtitle => 'Alle gescannten Titel entfernen';
@override
- String get libraryClearConfirmTitle => 'Clear Library';
+ String get libraryClearConfirmTitle => 'Bibliothek löschen';
@override
String get libraryClearConfirmMessage =>
- 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
+ 'Dadurch werden alle gescannten Titel aus Ihrer Bibliothek entfernt. Ihre eigentlichen Musikdateien werden nicht gelöscht.';
@override
- String get libraryAbout => 'About Local Library';
+ String get libraryAbout => 'Über die lokale Bibliothek';
@override
String get libraryAboutDescription =>
- 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
+ 'Durchsucht deine bestehende Musiksammlung, um Duplikate beim Herunterladen zu erkennen. Unterstützt die Formate FLAC, M4A, MP3, Opus und OGG. Metadaten werden, sofern verfügbar, aus den Dateitags gelesen.';
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'tracks',
- one: 'track',
+ other: '$count Titel',
+ one: '1 Titel',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
- return 'Last scanned: $time';
+ return 'Zuletzt gescannt: $time';
}
@override
- String get libraryLastScannedNever => 'Never';
+ String get libraryLastScannedNever => 'Nie';
@override
- String get libraryScanning => 'Scanning...';
+ String get libraryScanning => 'Scannen...';
@override
String libraryScanProgress(String progress, int total) {
- return '$progress% of $total files';
+ return '$progress% von $total Dateien';
}
@override
- String get libraryInLibrary => 'In Library';
+ String get libraryInLibrary => 'In Bibliothek';
@override
String libraryRemovedMissingFiles(int count) {
- return 'Removed $count missing files from library';
+ return 'Entfernte $count fehlende Dateien aus der Bibliothek';
}
@override
- String get libraryCleared => 'Library cleared';
+ String get libraryCleared => 'Bibliothek geleert';
@override
- String get libraryStorageAccessRequired => 'Storage Access Required';
+ String get libraryStorageAccessRequired => 'Speicherzugriff erforderlich';
@override
String get libraryStorageAccessMessage =>
- 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
+ 'SpotiFLAC benötigt Speicherzugriff, um deine Musikbibliothek zu scannen. Bitte erteile die Berechtigung in den Einstellungen.';
@override
- String get libraryFolderNotExist => 'Selected folder does not exist';
+ String get libraryFolderNotExist => 'Der ausgewählte Ordner existiert nicht';
@override
- String get librarySourceDownloaded => 'Downloaded';
+ String get librarySourceDownloaded => 'Heruntergeladen';
@override
- String get librarySourceLocal => 'Local';
+ String get librarySourceLocal => 'Lokal';
@override
- String get libraryFilterAll => 'All';
+ String get libraryFilterAll => 'Alle';
@override
- String get libraryFilterDownloaded => 'Downloaded';
+ String get libraryFilterDownloaded => 'Heruntergeladen';
@override
- String get libraryFilterLocal => 'Local';
+ String get libraryFilterLocal => 'Lokal';
@override
- String get libraryFilterTitle => 'Filters';
+ String get libraryFilterTitle => 'Filter';
@override
- String get libraryFilterReset => 'Reset';
+ String get libraryFilterReset => 'Zurücksetzen';
@override
- String get libraryFilterApply => 'Apply';
+ String get libraryFilterApply => 'Anwenden';
@override
- String get libraryFilterSource => 'Source';
+ String get libraryFilterSource => 'Quelle';
@override
- String get libraryFilterQuality => 'Quality';
+ String get libraryFilterQuality => 'Qualität';
@override
String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
@@ -1774,30 +1894,30 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryFilterQualityCD => 'CD (16bit)';
@override
- String get libraryFilterQualityLossy => 'Lossy';
+ String get libraryFilterQualityLossy => 'Verlustbehaftet';
@override
String get libraryFilterFormat => 'Format';
@override
- String get libraryFilterSort => 'Sort';
+ String get libraryFilterSort => 'Sortieren';
@override
- String get libraryFilterSortLatest => 'Latest';
+ String get libraryFilterSortLatest => 'Neuste';
@override
- String get libraryFilterSortOldest => 'Oldest';
+ String get libraryFilterSortOldest => 'Älteste';
@override
- String get timeJustNow => 'Just now';
+ String get timeJustNow => 'Gerade eben';
@override
String timeMinutesAgo(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: '$count minutes ago',
- one: '1 minute ago',
+ other: 'vor $count Minuten',
+ one: 'vor $count Minute',
);
return '$_temp0';
}
@@ -1807,199 +1927,201 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: '$count hours ago',
- one: '1 hour ago',
+ other: 'vor $count Stunden',
+ one: 'vor $count Stunde',
);
return '$_temp0';
}
@override
- String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
+ String get tutorialWelcomeTitle => 'Willkommen bei SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
- 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
+ 'Lass uns lernen, wie du deine Lieblingsmusik in verlustfreier Qualität herunterlädst. Dieses schnelle Tutorial zeigt dir die Grundlagen.';
@override
String get tutorialWelcomeTip1 =>
- 'Download music from Spotify, Deezer, or paste any supported URL';
+ 'Lade Musik von Spotify, Deezer herunter oder jeden unterstützten Link einfügen';
@override
String get tutorialWelcomeTip2 =>
- 'FLAC-Qualität von Tidal, Qobuz oder Deezer';
+ 'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik';
@override
String get tutorialWelcomeTip3 =>
- 'Automatic metadata, cover art, and lyrics embedding';
+ 'Automatische Metadaten, Cover und Lyrics einbetten';
@override
- String get tutorialSearchTitle => 'Finding Music';
+ String get tutorialSearchTitle => 'Suche Musik';
@override
String get tutorialSearchDesc =>
- 'There are two easy ways to find music you want to download.';
+ 'Es gibt zwei einfache Möglichkeiten, Musik zu finden, die du herunterladen möchtest.';
@override
- String get tutorialDownloadTitle => 'Downloading Music';
+ String get tutorialDownloadTitle => 'Musik wird heruntergeladen';
@override
String get tutorialDownloadDesc =>
- 'Downloading music is simple and fast. Here\'s how it works.';
+ 'Das Herunterladen von Musik ist einfach und schnell. So funktioniert es.';
@override
- String get tutorialLibraryTitle => 'Your Library';
+ String get tutorialLibraryTitle => 'Deine Bibliothek';
@override
String get tutorialLibraryDesc =>
- 'All your downloaded music is organized in the Library tab.';
+ 'Die gesamte heruntergeladene Musik ist in der Bibliothek organisiert.';
@override
String get tutorialLibraryTip1 =>
- 'View download progress and queue in the Library tab';
+ 'Fortschritt und Warteschlange im Bibliothek‑Tab anzeigen';
@override
String get tutorialLibraryTip2 =>
- 'Tap any track to play it with your music player';
+ 'Tippe auf einen Titel, um ihn mit deinem Musikplayer abzuspielen';
@override
String get tutorialLibraryTip3 =>
- 'Switch between list and grid view for better browsing';
+ 'Wechsle zwischen Listen- und Gitteransicht für ein besseres Surfen';
@override
- String get tutorialExtensionsTitle => 'Extensions';
+ String get tutorialExtensionsTitle => 'Erweiterungen';
@override
String get tutorialExtensionsDesc =>
- 'Extend the app\'s capabilities with community extensions.';
+ 'Erweitere die Fähigkeiten der App mit Community-Erweiterungen.';
@override
String get tutorialExtensionsTip1 =>
- 'Browse the Store tab to discover useful extensions';
+ 'Im Store Tab findest du nützliche Erweiterungen';
@override
String get tutorialExtensionsTip2 =>
- 'Add new download providers or search sources';
+ 'Neue Download- oder Suchanbieter hinzufügen';
@override
String get tutorialExtensionsTip3 =>
- 'Get lyrics, enhanced metadata, and more features';
+ 'Lyrics, erweiterte Metadaten und mehr Funktionen erhalten';
@override
- String get tutorialSettingsTitle => 'Customize Your Experience';
+ String get tutorialSettingsTitle => 'Passe deine Benutzererfahrung an';
@override
String get tutorialSettingsDesc =>
- 'Personalize the app in Settings to match your preferences.';
+ 'Personalisiere die App in den Einstellungen nach deiner Präferenz.';
@override
String get tutorialSettingsTip1 =>
- 'Change download location and folder organization';
+ 'Downloadverzeichnis und Ordnerorganisation ändern';
@override
String get tutorialSettingsTip2 =>
- 'Set default audio quality and format preferences';
+ 'Standard Audioqualität und Formateinstellungen festlegen';
@override
- String get tutorialSettingsTip3 => 'Customize app theme and appearance';
+ String get tutorialSettingsTip3 => 'App-Design und Aussehen anpassen';
@override
String get tutorialReadyMessage =>
- 'You\'re all set! Start downloading your favorite music now.';
+ 'Das ist alles! Lade jetzt deine Lieblingsmusik herunter.';
@override
- String get libraryForceFullScan => 'Force Full Scan';
+ String get libraryForceFullScan => 'Vollen Neu-Scan erzwingen';
@override
- String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
+ String get libraryForceFullScanSubtitle =>
+ 'Alle Dateien erneut scannen und Cache ignorieren';
@override
- String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
+ String get cleanupOrphanedDownloads => 'Verwaiste Downloads bereinigen';
@override
String get cleanupOrphanedDownloadsSubtitle =>
- 'Remove history entries for files that no longer exist';
+ 'Verlaufseinträge für Dateien löschen, die nicht mehr existieren';
@override
String cleanupOrphanedDownloadsResult(int count) {
- return 'Removed $count orphaned entries from history';
+ return 'Entfernte $count verwaiste Einträge aus dem Verlauf';
}
@override
- String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
+ String get cleanupOrphanedDownloadsNone =>
+ 'Keine verwaisten Einträge gefunden';
@override
- String get cacheTitle => 'Storage & Cache';
+ String get cacheTitle => 'Speicher & Cache';
@override
- String get cacheSummaryTitle => 'Cache overview';
+ String get cacheSummaryTitle => 'Cache-Übersicht';
@override
String get cacheSummarySubtitle =>
- 'Clearing cache will not remove downloaded music files.';
+ 'Das Leeren des Caches entfernt nicht heruntergeladene Musikdateien.';
@override
String cacheEstimatedTotal(String size) {
- return 'Estimated cache usage: $size';
+ return 'Geschätzte Cache-Größe: $size';
}
@override
- String get cacheSectionStorage => 'Cached Data';
+ String get cacheSectionStorage => 'Zwischengespeicherte Daten';
@override
- String get cacheSectionMaintenance => 'Maintenance';
+ String get cacheSectionMaintenance => 'Wartung';
@override
- String get cacheAppDirectory => 'App cache directory';
+ String get cacheAppDirectory => 'App-Cache Verzeichnis';
@override
String get cacheAppDirectoryDesc =>
- 'HTTP responses, WebView data, and other temporary app data.';
+ 'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.';
@override
- String get cacheTempDirectory => 'Temporary directory';
+ String get cacheTempDirectory => 'Temporäres Verzeichnis';
@override
String get cacheTempDirectoryDesc =>
- 'Temporary files from downloads and audio conversion.';
+ 'Temporäre Dateien von Downloads und Audio-Konvertierung.';
@override
- String get cacheCoverImage => 'Cover image cache';
+ String get cacheCoverImage => 'Cover-Cache';
@override
String get cacheCoverImageDesc =>
- 'Downloaded album and track cover art. Will re-download when viewed.';
+ 'Album- und Titelcover heruntergeladen. Werden erneut heruntergeladen.';
@override
- String get cacheLibraryCover => 'Library cover cache';
+ String get cacheLibraryCover => 'Bibliotheks-Cover-Cache';
@override
String get cacheLibraryCoverDesc =>
- 'Cover art extracted from local music files. Will re-extract on next scan.';
+ 'Cover aus lokalen Musikdateien extrahiert. Wird beim nächsten Scannen neu extrahiert.';
@override
- String get cacheExploreFeed => 'Explore feed cache';
+ String get cacheExploreFeed => 'Feed-Cache entdecken';
@override
String get cacheExploreFeedDesc =>
- 'Explore tab content (new releases, trending). Will refresh on next visit.';
+ 'Startseiten-Inhalt (neue Releases, Trends). Wird bei einem Neustart aktualisiert.';
@override
- String get cacheTrackLookup => 'Track lookup cache';
+ String get cacheTrackLookup => 'Titel Such-Cache';
@override
String get cacheTrackLookupDesc =>
- 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
+ 'Spotify/Deezer Track-ID-Lookups. Das Löschen kann die nächsten Suchergebnisse verlangsamen.';
@override
String get cacheCleanupUnusedDesc =>
- 'Remove orphaned download history and library entries for missing files.';
+ 'Verwaisten Downloadverlauf und Bibliothekseinträge für fehlende Dateien entfernen.';
@override
- String get cacheNoData => 'No cached data';
+ String get cacheNoData => 'Keine gecachten Daten';
@override
String cacheSizeWithFiles(String size, int count) {
- return '$size in $count files';
+ return '$size in $count Dateien';
}
@override
@@ -2009,71 +2131,71 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String cacheEntries(int count) {
- return '$count entries';
+ return '$count Einträge';
}
@override
String cacheClearSuccess(String target) {
- return 'Cleared: $target';
+ return 'Entfernt: $target';
}
@override
- String get cacheClearConfirmTitle => 'Clear cache?';
+ String get cacheClearConfirmTitle => 'Cache leeren?';
@override
String cacheClearConfirmMessage(String target) {
- return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
+ return 'Dies löscht zwischengespeicherte Daten in $target. Die Musikdateien werden nicht gelöscht.';
}
@override
- String get cacheClearAllConfirmTitle => 'Clear all cache?';
+ String get cacheClearAllConfirmTitle => 'Gesamten Cache leeren?';
@override
String get cacheClearAllConfirmMessage =>
- 'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
+ 'Dadurch werden alle Cache-Kategorien auf dieser Seite gelöscht. Heruntergeladene Musikdateien werden nicht gelöscht.';
@override
- String get cacheClearAll => 'Clear all cache';
+ String get cacheClearAll => 'Gesamten Cache leeren';
@override
- String get cacheCleanupUnused => 'Cleanup unused data';
+ String get cacheCleanupUnused => 'Unbenutzte Daten bereinigen';
@override
String get cacheCleanupUnusedSubtitle =>
- 'Remove orphaned download history and missing library entries';
+ 'Verwaisten Downloadverlauf und fehlende Bibliothekseinträge löschen';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
- return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
+ return 'Bereinigung: $downloadCount verwaiste Downloads, $libraryCount fehlende Bibliothekseinträge';
}
@override
- String get cacheRefreshStats => 'Refresh stats';
+ String get cacheRefreshStats => 'Statistik aktualisieren';
@override
- String get trackSaveCoverArt => 'Save Cover Art';
+ String get trackSaveCoverArt => 'Cover speichern';
@override
- String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
+ String get trackSaveCoverArtSubtitle => 'Albumcover als .jpg Datei speichern';
@override
- String get trackSaveLyrics => 'Save Lyrics (.lrc)';
+ String get trackSaveLyrics => 'Lyrics als .lrc speichern';
@override
- String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
+ String get trackSaveLyricsSubtitle => 'Lade Lyrics als .lrc Datei';
@override
- String get trackSaveLyricsProgress => 'Saving lyrics...';
+ String get trackSaveLyricsProgress => 'Speichere Lyrics...';
@override
- String get trackReEnrich => 'Re-enrich';
+ String get trackReEnrich => 'Neu-anreichern';
@override
String get trackReEnrichOnlineSubtitle =>
- 'Search metadata online and embed into file';
+ 'Metadaten online suchen und in Datei einbinden';
@override
- String get trackEditMetadata => 'Edit Metadata';
+ String get trackEditMetadata => 'Metadaten bearbeiten';
@override
String trackCoverSaved(String fileName) {
@@ -2085,43 +2207,66 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String trackLyricsSaved(String fileName) {
- return 'Lyrics saved to $fileName';
+ return 'Lyrics in $fileName gespeichert';
}
@override
- String get trackReEnrichProgress => 'Re-enriching metadata...';
+ String get trackReEnrichProgress => 'Metadaten neu anreichern...';
@override
- String get trackReEnrichSearching => 'Searching metadata online...';
+ String get trackReEnrichSearching => 'Suche Metadaten online...';
@override
- String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
+ String get trackReEnrichSuccess => 'Metadaten erfolgreich neu angereichert';
@override
- String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ String get trackReEnrichFfmpegFailed =>
+ 'FFmpeg Metadaten-Einbettung fehlgeschlagen';
+
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
@override
String trackSaveFailed(String error) {
- return 'Failed: $error';
+ return 'Fehler: $error';
}
@override
- String get trackConvertFormat => 'Convert Format';
+ String get trackConvertFormat => 'Format konvertieren';
@override
- String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
+ String get trackConvertFormatSubtitle => 'In MP3 oder Opus konvertieren';
@override
- String get trackConvertTitle => 'Convert Audio';
+ String get trackConvertTitle => 'Audio konvertieren';
@override
- String get trackConvertTargetFormat => 'Target Format';
+ String get trackConvertTargetFormat => 'Zielformat';
@override
String get trackConvertBitrate => 'Bitrate';
@override
- String get trackConvertConfirmTitle => 'Confirm Conversion';
+ String get trackConvertConfirmTitle => 'Konvertierung bestätigen';
@override
String trackConvertConfirmMessage(
@@ -2129,198 +2274,259 @@ class AppLocalizationsDe extends AppLocalizations {
String targetFormat,
String bitrate,
) {
- return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
+ return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
}
@override
- String get trackConvertConverting => 'Converting audio...';
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
+ @override
+ String get trackConvertConverting => 'Konvertiere Audio...';
@override
String trackConvertSuccess(String format) {
- return 'Converted to $format successfully';
+ return 'Konvertiert in $format erfolgreich';
}
@override
- String get trackConvertFailed => 'Conversion failed';
+ String get trackConvertFailed => 'Konvertierung fehlgeschlagen';
@override
- String get actionCreate => 'Create';
+ String get cueSplitTitle => 'Split CUE Sheet';
@override
- String get collectionFoldersTitle => 'My folders';
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
- String get collectionWishlist => 'Wishlist';
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
@override
- String get collectionLoved => 'Loved';
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
@override
- String get collectionPlaylists => 'Playlists';
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
+ @override
+ String get actionCreate => 'Erstellen';
+
+ @override
+ String get collectionFoldersTitle => 'Meine Ordner';
+
+ @override
+ String get collectionWishlist => 'Wunschliste';
+
+ @override
+ String get collectionLoved => 'Lieblingssongs';
+
+ @override
+ String get collectionPlaylists => 'Playlisten';
@override
String get collectionPlaylist => 'Playlist';
@override
- String get collectionAddToPlaylist => 'Add to playlist';
+ String get collectionAddToPlaylist => 'Zur Playlist hinzufügen';
@override
- String get collectionCreatePlaylist => 'Create playlist';
+ String get collectionCreatePlaylist => 'Playlist erstellen';
@override
- String get collectionNoPlaylistsYet => 'No playlists yet';
+ String get collectionNoPlaylistsYet => 'Noch keine Playlists';
@override
String get collectionNoPlaylistsSubtitle =>
- 'Create a playlist to start categorizing tracks';
+ 'Playlist erstellen, um Titel zu kategorisieren';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: '$count tracks',
- one: '1 track',
+ other: '$count Titel',
+ one: '1 Titel',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
- return 'Added to \"$playlistName\"';
+ return 'Zu \"$playlistName \" hinzugefügt';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
- return 'Already in \"$playlistName\"';
+ return 'Bereits in \"$playlistName\"';
}
@override
- String get collectionPlaylistCreated => 'Playlist created';
+ String get collectionPlaylistCreated => 'Playlist erstellt';
@override
- String get collectionPlaylistNameHint => 'Playlist name';
+ String get collectionPlaylistNameHint => 'Playlist-Name';
@override
- String get collectionPlaylistNameRequired => 'Playlist name is required';
+ String get collectionPlaylistNameRequired => 'Playlist-Name ist erforderlich';
@override
- String get collectionRenamePlaylist => 'Rename playlist';
+ String get collectionRenamePlaylist => 'Playlist umbenennen';
@override
- String get collectionDeletePlaylist => 'Delete playlist';
+ String get collectionDeletePlaylist => 'Playlist löschen';
@override
String collectionDeletePlaylistMessage(String playlistName) {
- return 'Delete \"$playlistName\" and all tracks inside it?';
+ return 'Willst du \"$playlistName\" und alle darin enthaltenen Titel löschen?';
}
@override
- String get collectionPlaylistDeleted => 'Playlist deleted';
+ String get collectionPlaylistDeleted => 'Playlist gelöscht';
@override
- String get collectionPlaylistRenamed => 'Playlist renamed';
+ String get collectionPlaylistRenamed => 'Playlist umbenannt';
@override
- String get collectionWishlistEmptyTitle => 'Wishlist is empty';
+ String get collectionWishlistEmptyTitle => 'Wunschliste ist leer';
@override
String get collectionWishlistEmptySubtitle =>
- 'Tap + on tracks to save what you want to download later';
+ 'Tippe auf das + bei den Titeln, um sie zum späteren Herunterladen zu speichern';
@override
- String get collectionLovedEmptyTitle => 'Loved folder is empty';
+ String get collectionLovedEmptyTitle => 'Lieblingssongs sind leer';
@override
String get collectionLovedEmptySubtitle =>
- 'Tap love on tracks to keep your favorites';
+ 'Tippe auf das Herz, um deine Favoriten zu behalten';
@override
- String get collectionPlaylistEmptyTitle => 'Playlist is empty';
+ String get collectionPlaylistEmptyTitle => 'Die Playlist ist leer';
@override
String get collectionPlaylistEmptySubtitle =>
- 'Long-press + on any track to add it here';
+ 'Drücke lange + auf einem beliebigen Titel, um ihn hier hinzuzufügen';
@override
- String get collectionRemoveFromPlaylist => 'Remove from playlist';
+ String get collectionRemoveFromPlaylist => 'Von Playlist entfernen';
@override
- String get collectionRemoveFromFolder => 'Remove from folder';
+ String get collectionRemoveFromFolder => 'Aus Ordner entfernen';
@override
String collectionRemoved(String trackName) {
- return '\"$trackName\" removed';
+ return '\"$trackName\" entfernt';
}
@override
String collectionAddedToLoved(String trackName) {
- return '\"$trackName\" added to Loved';
+ return '\"$trackName\" zu Lieblingssongs hinzugefügt';
}
@override
String collectionRemovedFromLoved(String trackName) {
- return '\"$trackName\" removed from Loved';
+ return '\"$trackName\" aus Lieblingssongs entfernt';
}
@override
String collectionAddedToWishlist(String trackName) {
- return '\"$trackName\" added to Wishlist';
+ return '\"$trackName\" zur Wunschliste hinzugefügt';
}
@override
String collectionRemovedFromWishlist(String trackName) {
- return '\"$trackName\" removed from Wishlist';
+ return '\"$trackName\" aus der Wunschliste entfernt';
}
@override
- String get trackOptionAddToLoved => 'Add to Loved';
+ String get trackOptionAddToLoved => 'Zu Lieblingssongs hinzufügen';
@override
- String get trackOptionRemoveFromLoved => 'Remove from Loved';
+ String get trackOptionRemoveFromLoved => 'Aus Lieblingssongs entfernt';
@override
- String get trackOptionAddToWishlist => 'Add to Wishlist';
+ String get trackOptionAddToWishlist => 'Zur Wunschliste hinzufügen';
@override
- String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
+ String get trackOptionRemoveFromWishlist => 'Von der Wunschliste entfernen';
@override
- String get collectionPlaylistChangeCover => 'Change cover image';
+ String get collectionPlaylistChangeCover => 'Coverbild ändern';
@override
- String get collectionPlaylistRemoveCover => 'Remove cover image';
+ String get collectionPlaylistRemoveCover => 'Cover entfernen';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'tracks',
- one: 'track',
+ other: 'Titel',
+ one: 'Titel',
);
- return 'Share $count $_temp0';
+ return 'Teile $count $_temp0';
}
@override
- String get selectionShareNoFiles => 'No shareable files found';
+ String get selectionShareNoFiles => 'Keine teilbare Dateien gefunden';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'tracks',
- one: 'track',
+ other: 'Titel',
+ one: 'Titel',
);
- return 'Convert $count $_temp0';
+ return 'Konvertiere $count $_temp0';
}
@override
- String get selectionConvertNoConvertible => 'No convertible tracks selected';
+ String get selectionConvertNoConvertible =>
+ 'Keine konvertierbare Titel ausgewählt';
@override
- String get selectionBatchConvertConfirmTitle => 'Batch Convert';
+ String get selectionBatchConvertConfirmTitle => 'Batch-Konvertierung';
@override
String selectionBatchConvertConfirmMessage(
@@ -2328,35 +2534,460 @@ class AppLocalizationsDe extends AppLocalizations {
String format,
String bitrate,
) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'Titel',
+ one: 'Titel',
+ );
+ return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
+ }
+
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
- return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
- return 'Converting $current of $total...';
+ return 'Konvertiere $current von $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
- return 'Converted $success of $total tracks to $format';
+ return '$success von $total Titeln in $format konvertiert';
}
@override
String downloadedAlbumDownloadedCount(int count) {
- return '$count downloaded';
+ return '$count heruntergeladen';
}
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
- 'Artist folders use Album Artist when available';
+ 'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index c98cf0ee..594df94e 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogImport => 'Import';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1178,7 +1199,48 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeClearFilters => 'Clear filters';
@override
- String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
+ @override
+ String get extensionDefaultProvider => 'Default (Deezer)';
@override
String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -1327,6 +1389,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,25 @@ class AppLocalizationsEn extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -2083,6 +2196,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2092,7 +2227,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
- String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
+ String get trackConvertFormatSubtitle =>
+ 'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2115,6 +2251,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2126,6 +2274,54 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
@override
String get actionCreate => 'Create';
@@ -2320,6 +2516,17 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2342,4 +2549,418 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart
index efc7a016..d99eec4d 100644
--- a/lib/l10n/app_localizations_es.dart
+++ b/lib/l10n/app_localizations_es.dart
@@ -525,6 +525,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get dialogImport => 'Import';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1177,6 +1198,47 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1327,6 +1389,38 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,25 @@ class AppLocalizationsEs extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -2083,6 +2196,28 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2092,7 +2227,8 @@ class AppLocalizationsEs extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
- String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
+ String get trackConvertFormatSubtitle =>
+ 'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2115,6 +2251,18 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2126,6 +2274,54 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
@override
String get actionCreate => 'Create';
@@ -2320,6 +2516,17 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2342,6 +2549,420 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart
index 89d94355..5b1c8d81 100644
--- a/lib/l10n/app_localizations_fr.dart
+++ b/lib/l10n/app_localizations_fr.dart
@@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutAppDescription =>
- 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
+ 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get artistAlbums => 'Albums';
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get dialogImport => 'Import';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Discard';
@@ -690,6 +693,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -763,6 +777,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1179,6 +1200,47 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1329,6 +1391,38 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1635,6 +1729,25 @@ class AppLocalizationsFr extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -1811,7 +1924,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
- 'Audio en qualité FLAC depuis Tidal, Qobuz ou Deezer';
+ 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2085,6 +2198,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2117,6 +2252,18 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2128,6 +2275,54 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
@override
String get actionCreate => 'Create';
@@ -2322,6 +2517,17 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2344,4 +2550,418 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart
index 5b6ff05f..7c92f95c 100644
--- a/lib/l10n/app_localizations_hi.dart
+++ b/lib/l10n/app_localizations_hi.dart
@@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutAppDescription =>
- 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
+ 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get artistAlbums => 'Albums';
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogImport => 'Import';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1177,6 +1198,47 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1327,6 +1389,38 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,25 @@ class AppLocalizationsHi extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -1809,7 +1922,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
- 'Tidal, Qobuz, या Deezer से FLAC गुणवत्ता ऑडियो प्राप्त करें';
+ 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2083,6 +2196,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2115,6 +2250,18 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2126,6 +2273,54 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
@override
String get actionCreate => 'Create';
@@ -2320,6 +2515,17 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2342,4 +2548,418 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart
index 4312a41a..8b088f35 100644
--- a/lib/l10n/app_localizations_id.dart
+++ b/lib/l10n/app_localizations_id.dart
@@ -15,7 +15,7 @@ class AppLocalizationsId extends AppLocalizations {
String get navHome => 'Beranda';
@override
- String get navLibrary => 'Library';
+ String get navLibrary => 'Pustaka';
@override
String get navSettings => 'Pengaturan';
@@ -45,7 +45,7 @@ class AppLocalizationsId extends AppLocalizations {
String get historyFilterSingles => 'Single';
@override
- String get historySearchHint => 'Search history...';
+ String get historySearchHint => 'Cari riwayat...';
@override
String get settingsTitle => 'Pengaturan';
@@ -104,7 +104,7 @@ class AppLocalizationsId extends AppLocalizations {
String get appearanceHistoryViewList => 'Daftar';
@override
- String get appearanceHistoryViewGrid => 'Grid';
+ String get appearanceHistoryViewGrid => 'Kisi';
@override
String get optionsTitle => 'Opsi';
@@ -126,7 +126,7 @@ class AppLocalizationsId extends AppLocalizations {
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
@override
- String get optionsAutoFallback => 'Auto Fallback';
+ String get optionsAutoFallback => 'Cadangan Otomatis';
@override
String get optionsAutoFallbackSubtitle =>
@@ -217,7 +217,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String optionsSpotifyCredentialsConfigured(String clientId) {
- return 'Client ID: $clientId...';
+ return 'ID Klien: $clientId...';
}
@override
@@ -230,7 +230,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get optionsSpotifyDeprecationWarning =>
- 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
+ 'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
@override
String get extensionsTitle => 'Ekstensi';
@@ -283,7 +283,7 @@ class AppLocalizationsId extends AppLocalizations {
'Seniman berbakat yang membuat logo aplikasi kita yang indah!';
@override
- String get aboutTranslators => 'Translators';
+ String get aboutTranslators => 'Penerjemah';
@override
String get aboutSpecialThanks => 'Terima Kasih Khusus';
@@ -311,19 +311,19 @@ class AppLocalizationsId extends AppLocalizations {
'Sarankan fitur baru untuk aplikasi';
@override
- String get aboutTelegramChannel => 'Telegram Channel';
+ String get aboutTelegramChannel => 'Saluran Telegram';
@override
- String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+ String get aboutTelegramChannelSubtitle => 'Pengumuman dan pembaruan';
@override
- String get aboutTelegramChat => 'Telegram Community';
+ String get aboutTelegramChat => 'Komunitas Telegram';
@override
- String get aboutTelegramChatSubtitle => 'Chat with other users';
+ String get aboutTelegramChatSubtitle => 'Berbincang dengan pengguna lain';
@override
- String get aboutSocial => 'Social';
+ String get aboutSocial => 'Sosial';
@override
String get aboutApp => 'Aplikasi';
@@ -341,7 +341,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutSjdonadoDesc =>
- 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
+ 'Pencipta I Don\'t Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!';
@override
String get aboutDabMusic => 'DAB Music';
@@ -355,11 +355,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get aboutSpotiSaverDesc =>
- 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
+ 'Tidal perangkat streaming FLAC resolusi tinggi. Bagian penting dari teka-teki tanpa kehilangan kualitas!';
@override
String get aboutAppDescription =>
- 'Unduh lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
+ 'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
@override
String get artistAlbums => 'Album';
@@ -456,7 +456,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get setupIcloudNotSupported =>
- 'iCloud Drive is not supported. Please use the app Documents folder.';
+ 'iCloud Drive tidak didukung. Silakan gunakan folder Dokumen di aplikasi.';
@override
String get setupDownloadInFlac => 'Unduh lagu Spotify dalam format FLAC';
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get dialogImport => 'Impor';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Buang';
@@ -593,7 +596,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String csvImportTracks(int count) {
- return '$count tracks from CSV';
+ return '$count trek dari CSV';
}
@override
@@ -613,7 +616,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String snackbarAlreadyInLibrary(String trackName) {
- return '\"$trackName\" already exists in your library';
+ return '\"$trackName\" sudah ada di perpustakaan Anda';
}
@override
@@ -691,6 +694,17 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
+ @override
+ String get errorUrlNotRecognized => 'Link tidak dikenali';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Gagal memuat konten dari link ini. Silakan coba lagi.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Tidak dapat memuat $item: sumber ekstensi tidak ada';
@@ -755,15 +769,22 @@ class AppLocalizationsId extends AppLocalizations {
String get filenameFormat => 'Format Nama File';
@override
- String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
+ String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
- 'Aktifkan tag format untuk padding nomor lagu dan pola tanggal';
+ 'Enable formatted tags for track padding and date patterns';
@override
String get folderOrganizationNone => 'Tidak ada';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'Berdasarkan Artis';
@@ -1182,6 +1203,47 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get storeClearFilters => 'Hapus filter';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1334,6 +1396,38 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@@ -1343,10 +1437,10 @@ class AppLocalizationsId extends AppLocalizations {
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
- String get youtubeOpusBitrateTitle => 'Bitrate Opus YouTube';
+ String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
- String get youtubeMp3BitrateTitle => 'Bitrate MP3 YouTube';
+ String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1640,6 +1734,25 @@ class AppLocalizationsId extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -1684,8 +1797,8 @@ class AppLocalizationsId extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'trek',
- one: 'trek',
+ other: 'tracks',
+ one: 'track',
);
return '$_temp0';
}
@@ -1816,7 +1929,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
- 'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Deezer';
+ 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2090,6 +2203,28 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Antrekan FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Mencari kecocokan FLAC... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2099,7 +2234,8 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
- String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
+ String get trackConvertFormatSubtitle =>
+ 'Konversi ke MP3, Opus, ALAC, atau FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2122,6 +2258,18 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Konversi lossless — tanpa kehilangan kualitas';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2134,10 +2282,58 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFailed => 'Conversion failed';
@override
- String get actionCreate => 'Buat';
+ String get cueSplitTitle => 'Split CUE Sheet';
@override
- String get collectionFoldersTitle => 'Folder saya';
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
+ @override
+ String get actionCreate => 'Create';
+
+ @override
+ String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@@ -2146,172 +2342,171 @@ class AppLocalizationsId extends AppLocalizations {
String get collectionLoved => 'Loved';
@override
- String get collectionPlaylists => 'Playlist';
+ String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
- String get collectionAddToPlaylist => 'Tambahkan ke playlist';
+ String get collectionAddToPlaylist => 'Add to playlist';
@override
- String get collectionCreatePlaylist => 'Buat playlist';
+ String get collectionCreatePlaylist => 'Create playlist';
@override
- String get collectionNoPlaylistsYet => 'Belum ada playlist';
+ String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
- 'Buat playlist untuk mulai mengategorikan lagu';
+ 'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: '$count lagu',
- one: '1 lagu',
+ other: '$count tracks',
+ one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
- return 'Ditambahkan ke \"$playlistName\"';
+ return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
- return 'Sudah ada di \"$playlistName\"';
+ return 'Already in \"$playlistName\"';
}
@override
- String get collectionPlaylistCreated => 'Playlist berhasil dibuat';
+ String get collectionPlaylistCreated => 'Playlist created';
@override
- String get collectionPlaylistNameHint => 'Nama playlist';
+ String get collectionPlaylistNameHint => 'Playlist name';
@override
- String get collectionPlaylistNameRequired => 'Nama playlist wajib diisi';
+ String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
- String get collectionRenamePlaylist => 'Ubah nama playlist';
+ String get collectionRenamePlaylist => 'Rename playlist';
@override
- String get collectionDeletePlaylist => 'Hapus playlist';
+ String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
- return 'Hapus \"$playlistName\" beserta semua lagunya?';
+ return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
- String get collectionPlaylistDeleted => 'Playlist dihapus';
+ String get collectionPlaylistDeleted => 'Playlist deleted';
@override
- String get collectionPlaylistRenamed => 'Nama playlist diperbarui';
+ String get collectionPlaylistRenamed => 'Playlist renamed';
@override
- String get collectionWishlistEmptyTitle => 'Wishlist masih kosong';
+ String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
- 'Tap + di lagu untuk menyimpan yang ingin diunduh nanti';
+ 'Tap + on tracks to save what you want to download later';
@override
- String get collectionLovedEmptyTitle => 'Folder Loved masih kosong';
+ String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
- 'Tap love di lagu untuk menyimpan favoritmu';
+ 'Tap love on tracks to keep your favorites';
@override
- String get collectionPlaylistEmptyTitle => 'Playlist masih kosong';
+ String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
- 'Tekan lama tombol + pada lagu untuk menambahkannya ke sini';
+ 'Long-press + on any track to add it here';
@override
- String get collectionRemoveFromPlaylist => 'Hapus dari playlist';
+ String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
- String get collectionRemoveFromFolder => 'Hapus dari folder';
+ String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
- return '\"$trackName\" dihapus';
+ return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
- return '\"$trackName\" ditambahkan ke Loved';
+ return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
- return '\"$trackName\" dihapus dari Loved';
+ return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
- return '\"$trackName\" ditambahkan ke Wishlist';
+ return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
- return '\"$trackName\" dihapus dari Wishlist';
+ return '\"$trackName\" removed from Wishlist';
}
@override
- String get trackOptionAddToLoved => 'Tambahkan ke Loved';
+ String get trackOptionAddToLoved => 'Add to Loved';
@override
- String get trackOptionRemoveFromLoved => 'Hapus dari Loved';
+ String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
- String get trackOptionAddToWishlist => 'Tambahkan ke Wishlist';
+ String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
- String get trackOptionRemoveFromWishlist => 'Hapus dari Wishlist';
+ String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
- String get collectionPlaylistChangeCover => 'Ubah gambar sampul';
+ String get collectionPlaylistChangeCover => 'Change cover image';
@override
- String get collectionPlaylistRemoveCover => 'Hapus gambar sampul';
+ String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'trek',
- one: 'trek',
+ other: 'tracks',
+ one: 'track',
);
- return 'Bagikan $count $_temp0';
+ return 'Share $count $_temp0';
}
@override
- String get selectionShareNoFiles => 'Tidak ada file yang dapat dibagikan';
+ String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'trek',
- one: 'trek',
+ other: 'tracks',
+ one: 'track',
);
- return 'Konversi $count $_temp0';
+ return 'Convert $count $_temp0';
}
@override
- String get selectionConvertNoConvertible =>
- 'Tidak ada trek yang dapat dikonversi dipilih';
+ String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
- String get selectionBatchConvertConfirmTitle => 'Konversi Massal';
+ String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
@@ -2322,20 +2517,31 @@ class AppLocalizationsId extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'trek',
- one: 'trek',
+ other: 'tracks',
+ one: 'track',
);
- return 'Konversi $count $_temp0 ke $format pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
+ return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
+ }
+
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
- return 'Mengonversi $current dari $total...';
+ return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
- return 'Berhasil mengonversi $success dari $total trek ke $format';
+ return 'Converted $success of $total tracks to $format';
}
@override
@@ -2350,4 +2556,418 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Buat folder sumber playlist';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Unduhan dari playlist hanya memakai struktur folder normal.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart
index 9aa5a2fb..5c454b45 100644
--- a/lib/l10n/app_localizations_ja.dart
+++ b/lib/l10n/app_localizations_ja.dart
@@ -15,7 +15,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get navHome => 'ホーム';
@override
- String get navLibrary => 'Library';
+ String get navLibrary => 'ライブラリ';
@override
String get navSettings => '設定';
@@ -160,7 +160,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String optionsConcurrentParallel(int count) {
- return '$count parallel downloads';
+ return '$count 件の分割ダウンロード';
}
@override
@@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutAppDescription =>
- 'Tidal、Qobuz から Spotify のトラックをロスレス品質でダウンロードします。';
+ 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
@override
String get artistAlbums => 'アルバム';
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogImport => 'インポート';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => '破棄';
@@ -683,6 +686,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get errorNoTracksFound => 'トラックがありません';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return '$item を読み込めません: 拡張ソースがありません';
@@ -756,6 +770,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get folderOrganizationNone => '構成がありません';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'アーティスト別';
@@ -1111,7 +1132,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackLyricsLoadFailed => '歌詞の読み込みに失敗しました';
@override
- String get trackEmbedLyrics => 'Embed Lyrics';
+ String get trackEmbedLyrics => '歌詞を埋め込む';
@override
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@@ -1171,6 +1192,47 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get storeClearFilters => 'フィルターを消去';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)';
@@ -1317,6 +1379,38 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@@ -1325,10 +1419,10 @@ class AppLocalizationsJa extends AppLocalizations {
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
+ String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
@override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
+ String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1375,20 +1469,20 @@ class AppLocalizationsJa extends AppLocalizations {
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
@override
- String get settingsAutoExportFailed => 'Auto-export failed downloads';
+ String get settingsAutoExportFailed => 'ダウンロードの自動エクスポートに失敗しました';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
- String get settingsDownloadNetwork => 'Download Network';
+ String get settingsDownloadNetwork => 'ダウンロードネットワーク';
@override
- String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
+ String get settingsDownloadNetworkAny => 'Wi-Fi + モバイルデータ';
@override
- String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
+ String get settingsDownloadNetworkWifiOnly => 'Wi-Fi のみ';
@override
String get settingsDownloadNetworkSubtitle =>
@@ -1419,7 +1513,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get albumFolderYearAlbumSubtitle => 'アルバム/[2005] アルバム名/';
@override
- String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+ String get albumFolderArtistAlbumSingles => 'アーティスト / アルバム + シングル';
@override
String get albumFolderArtistAlbumSinglesSubtitle =>
@@ -1485,7 +1579,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get recentEmpty => 'No recent items yet';
@override
- String get recentShowAllDownloads => 'Show All Downloads';
+ String get recentShowAllDownloads => 'すべてのダウンロードを表示';
@override
String recentPlaylistInfo(String name) {
@@ -1559,10 +1653,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get discographyFailedToFetch => '一部のアルバムの取得に失敗しました';
@override
- String get sectionStorageAccess => 'Storage Access';
+ String get sectionStorageAccess => 'ストレージアクセス';
@override
- String get allFilesAccess => 'All Files Access';
+ String get allFilesAccess => 'すべてのファイルへのアクセス';
@override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
@@ -1583,35 +1677,35 @@ class AppLocalizationsJa extends AppLocalizations {
'All Files Access disabled. The app will use limited storage access.';
@override
- String get settingsLocalLibrary => 'Local Library';
+ String get settingsLocalLibrary => 'ローカルライブラリ';
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
- String get settingsCache => 'Storage & Cache';
+ String get settingsCache => 'ストレージとキャッシュ';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
- String get libraryTitle => 'Local Library';
+ String get libraryTitle => 'ローカルライブラリ';
@override
- String get libraryScanSettings => 'Scan Settings';
+ String get libraryScanSettings => 'スキャン設定';
@override
- String get libraryEnableLocalLibrary => 'Enable Local Library';
+ String get libraryEnableLocalLibrary => 'ローカルライブラリを有効';
@override
String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music';
@override
- String get libraryFolder => 'Library Folder';
+ String get libraryFolder => 'ライブラリのフォルダ';
@override
- String get libraryFolderHint => 'Tap to select folder';
+ String get libraryFolderHint => 'タップでフォルダを選択';
@override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
@@ -1621,13 +1715,32 @@ class AppLocalizationsJa extends AppLocalizations {
'Show when searching for existing tracks';
@override
- String get libraryActions => 'Actions';
+ String get libraryAutoScan => 'Auto Scan';
@override
- String get libraryScan => 'Scan Library';
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
@override
- String get libraryScanSubtitle => 'Scan for audio files';
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
+ @override
+ String get libraryActions => 'アクション';
+
+ @override
+ String get libraryScan => 'ライブラリをスキャン';
+
+ @override
+ String get libraryScanSubtitle => 'オーディオファイルをスキャン';
@override
String get libraryScanSelectFolderFirst => 'Select a folder first';
@@ -1640,20 +1753,20 @@ class AppLocalizationsJa extends AppLocalizations {
'Remove entries for files that no longer exist';
@override
- String get libraryClear => 'Clear Library';
+ String get libraryClear => 'ライブラリを消去';
@override
String get libraryClearSubtitle => 'Remove all scanned tracks';
@override
- String get libraryClearConfirmTitle => 'Clear Library';
+ String get libraryClearConfirmTitle => 'ライブラリを消去';
@override
String get libraryClearConfirmMessage =>
'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
@override
- String get libraryAbout => 'About Local Library';
+ String get libraryAbout => 'ローカルライブラリについて';
@override
String get libraryAboutDescription =>
@@ -1672,14 +1785,14 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String libraryLastScanned(String time) {
- return 'Last scanned: $time';
+ return '最終スキャン: $time';
}
@override
String get libraryLastScannedNever => 'Never';
@override
- String get libraryScanning => 'Scanning...';
+ String get libraryScanning => 'スキャン中...';
@override
String libraryScanProgress(String progress, int total) {
@@ -1687,7 +1800,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
- String get libraryInLibrary => 'In Library';
+ String get libraryInLibrary => 'ライブラリ内';
@override
String libraryRemovedMissingFiles(int count) {
@@ -1698,7 +1811,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryCleared => 'Library cleared';
@override
- String get libraryStorageAccessRequired => 'Storage Access Required';
+ String get libraryStorageAccessRequired => 'ストレージアクセスが必要です';
@override
String get libraryStorageAccessMessage =>
@@ -1708,37 +1821,37 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryFolderNotExist => 'Selected folder does not exist';
@override
- String get librarySourceDownloaded => 'Downloaded';
+ String get librarySourceDownloaded => 'ダウンロード済み';
@override
- String get librarySourceLocal => 'Local';
+ String get librarySourceLocal => 'ローカル';
@override
- String get libraryFilterAll => 'All';
+ String get libraryFilterAll => 'すべて';
@override
- String get libraryFilterDownloaded => 'Downloaded';
+ String get libraryFilterDownloaded => 'ダウンロード済み';
@override
- String get libraryFilterLocal => 'Local';
+ String get libraryFilterLocal => 'ローカル';
@override
- String get libraryFilterTitle => 'Filters';
+ String get libraryFilterTitle => 'フィルター';
@override
- String get libraryFilterReset => 'Reset';
+ String get libraryFilterReset => 'リセット';
@override
- String get libraryFilterApply => 'Apply';
+ String get libraryFilterApply => '適用';
@override
- String get libraryFilterSource => 'Source';
+ String get libraryFilterSource => 'ソース';
@override
- String get libraryFilterQuality => 'Quality';
+ String get libraryFilterQuality => '品質';
@override
- String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
+ String get libraryFilterQualityHiRes => 'ハイレゾ (24bit)';
@override
String get libraryFilterQualityCD => 'CD (16bit)';
@@ -1747,7 +1860,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get libraryFilterQualityLossy => 'Lossy';
@override
- String get libraryFilterFormat => 'Format';
+ String get libraryFilterFormat => '形式';
@override
String get libraryFilterSort => 'Sort';
@@ -1766,8 +1879,8 @@ class AppLocalizationsJa extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: '$count minutes ago',
- one: '1 minute ago',
+ other: '$count 分前',
+ one: '1 分前',
);
return '$_temp0';
}
@@ -1777,14 +1890,14 @@ class AppLocalizationsJa extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: '$count hours ago',
- one: '1 hour ago',
+ other: '$count 時間前',
+ one: '1 時間前',
);
return '$_temp0';
}
@override
- String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
+ String get tutorialWelcomeTitle => 'SpotiFLAC へようこそ!';
@override
String get tutorialWelcomeDesc =>
@@ -1795,7 +1908,8 @@ class AppLocalizationsJa extends AppLocalizations {
'Download music from Spotify, Deezer, or paste any supported URL';
@override
- String get tutorialWelcomeTip2 => 'Tidal、Qobuz、Deezer から FLAC 品質のオーディオを取得';
+ String get tutorialWelcomeTip2 =>
+ 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -1809,14 +1923,14 @@ class AppLocalizationsJa extends AppLocalizations {
'There are two easy ways to find music you want to download.';
@override
- String get tutorialDownloadTitle => 'Downloading Music';
+ String get tutorialDownloadTitle => '音楽をダウンロード中';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
- String get tutorialLibraryTitle => 'Your Library';
+ String get tutorialLibraryTitle => 'あなたのライブラリ';
@override
String get tutorialLibraryDesc =>
@@ -1835,7 +1949,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Switch between list and grid view for better browsing';
@override
- String get tutorialExtensionsTitle => 'Extensions';
+ String get tutorialExtensionsTitle => '拡張';
@override
String get tutorialExtensionsDesc =>
@@ -1876,7 +1990,7 @@ class AppLocalizationsJa extends AppLocalizations {
'You\'re all set! Start downloading your favorite music now.';
@override
- String get libraryForceFullScan => 'Force Full Scan';
+ String get libraryForceFullScan => '強制フルスキャン';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@@ -1897,10 +2011,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
- String get cacheTitle => 'Storage & Cache';
+ String get cacheTitle => 'ストレージとキャッシュ';
@override
- String get cacheSummaryTitle => 'Cache overview';
+ String get cacheSummaryTitle => 'キャッシュの概要';
@override
String get cacheSummarySubtitle =>
@@ -1912,34 +2026,34 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
- String get cacheSectionStorage => 'Cached Data';
+ String get cacheSectionStorage => 'キャッシュ済みデータ';
@override
- String get cacheSectionMaintenance => 'Maintenance';
+ String get cacheSectionMaintenance => 'メンテナンス';
@override
- String get cacheAppDirectory => 'App cache directory';
+ String get cacheAppDirectory => 'アプリキャッシュのディレクトリ';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
- String get cacheTempDirectory => 'Temporary directory';
+ String get cacheTempDirectory => '一時ディレクトリ';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
- String get cacheCoverImage => 'Cover image cache';
+ String get cacheCoverImage => 'カバー画像のキャッシュ';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
- String get cacheLibraryCover => 'Library cover cache';
+ String get cacheLibraryCover => 'ライブラリのカバーキャッシュ';
@override
String get cacheLibraryCoverDesc =>
@@ -1964,7 +2078,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Remove orphaned download history and library entries for missing files.';
@override
- String get cacheNoData => 'No cached data';
+ String get cacheNoData => 'キャッシュデータはありません';
@override
String cacheSizeWithFiles(String size, int count) {
@@ -1978,16 +2092,16 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String cacheEntries(int count) {
- return '$count entries';
+ return '$count 個のエントリ';
}
@override
String cacheClearSuccess(String target) {
- return 'Cleared: $target';
+ return '消去済み: $target';
}
@override
- String get cacheClearConfirmTitle => 'Clear cache?';
+ String get cacheClearConfirmTitle => 'キャッシュを消去しますか?';
@override
String cacheClearConfirmMessage(String target) {
@@ -1995,17 +2109,17 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
- String get cacheClearAllConfirmTitle => 'Clear all cache?';
+ String get cacheClearAllConfirmTitle => 'すべてのキャッシュを消去しますか?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
- String get cacheClearAll => 'Clear all cache';
+ String get cacheClearAll => 'すべてのキャッシュを消去';
@override
- String get cacheCleanupUnused => 'Cleanup unused data';
+ String get cacheCleanupUnused => '未使用のデータを削除';
@override
String get cacheCleanupUnusedSubtitle =>
@@ -2017,16 +2131,16 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
- String get cacheRefreshStats => 'Refresh stats';
+ String get cacheRefreshStats => '状態を更新';
@override
- String get trackSaveCoverArt => 'Save Cover Art';
+ String get trackSaveCoverArt => 'カバー画像を保存';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
- String get trackSaveLyrics => 'Save Lyrics (.lrc)';
+ String get trackSaveLyrics => '歌詞を保存 (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@@ -2042,7 +2156,7 @@ class AppLocalizationsJa extends AppLocalizations {
'Search metadata online and embed into file';
@override
- String get trackEditMetadata => 'Edit Metadata';
+ String get trackEditMetadata => 'メタデータを編集';
@override
String trackCoverSaved(String fileName) {
@@ -2070,27 +2184,49 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
- String trackSaveFailed(String error) {
- return 'Failed: $error';
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
- String get trackConvertFormat => 'Convert Format';
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
@override
- String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
@override
- String get trackConvertTitle => 'Convert Audio';
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
@override
- String get trackConvertTargetFormat => 'Target Format';
+ String trackSaveFailed(String error) {
+ return '失敗: $error';
+ }
@override
- String get trackConvertBitrate => 'Bitrate';
+ String get trackConvertFormat => '変換の形式';
@override
- String get trackConvertConfirmTitle => 'Confirm Conversion';
+ String get trackConvertFormatSubtitle => 'MP3 または Opus に変換';
+
+ @override
+ String get trackConvertTitle => 'オーディオを変換';
+
+ @override
+ String get trackConvertTargetFormat => 'ターゲットの形式';
+
+ @override
+ String get trackConvertBitrate => 'ビットレート';
+
+ @override
+ String get trackConvertConfirmTitle => '変換を確認';
@override
String trackConvertConfirmMessage(
@@ -2102,7 +2238,19 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
- String get trackConvertConverting => 'Converting audio...';
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
+ @override
+ String get trackConvertConverting => 'オーディオを変換中...';
@override
String trackConvertSuccess(String format) {
@@ -2110,7 +2258,55 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
- String get trackConvertFailed => 'Conversion failed';
+ String get trackConvertFailed => '変換に失敗しました';
+
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@@ -2306,6 +2502,17 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2328,4 +2535,418 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart
index 3796e499..3dc6f4f9 100644
--- a/lib/l10n/app_localizations_ko.dart
+++ b/lib/l10n/app_localizations_ko.dart
@@ -45,41 +45,40 @@ class AppLocalizationsKo extends AppLocalizations {
String get historyFilterSingles => 'Singles';
@override
- String get historySearchHint => 'Search history...';
+ String get historySearchHint => '검색 기록...';
@override
String get settingsTitle => 'Settings';
@override
- String get settingsDownload => 'Download';
+ String get settingsDownload => '다운로드';
@override
- String get settingsAppearance => 'Appearance';
+ String get settingsAppearance => '외관';
@override
- String get settingsOptions => 'Options';
+ String get settingsOptions => '옵션';
@override
- String get settingsExtensions => 'Extensions';
+ String get settingsExtensions => '확장 기능';
@override
- String get settingsAbout => 'About';
+ String get settingsAbout => '정보';
@override
- String get downloadTitle => 'Download';
+ String get downloadTitle => '다운로드';
@override
- String get downloadAskQualitySubtitle =>
- 'Show quality picker for each download';
+ String get downloadAskQualitySubtitle => '다운로드를 할 때마다 품질을 선택하도록 합니다';
@override
- String get downloadFilenameFormat => 'Filename Format';
+ String get downloadFilenameFormat => '파일 이름 형식';
@override
- String get downloadFolderOrganization => 'Folder Organization';
+ String get downloadFolderOrganization => '폴더 분류 형식';
@override
- String get appearanceTitle => 'Appearance';
+ String get appearanceTitle => '외관';
@override
String get appearanceThemeSystem => 'System';
@@ -94,10 +93,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get appearanceDynamicColor => 'Dynamic Color';
@override
- String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
+ String get appearanceDynamicColorSubtitle => '배경 화면을 참고하여 강조 색상이 지정됩니다';
@override
- String get appearanceHistoryView => 'History View';
+ String get appearanceHistoryView => '기록 정렬 방식';
@override
String get appearanceHistoryViewList => 'List';
@@ -106,112 +105,104 @@ class AppLocalizationsKo extends AppLocalizations {
String get appearanceHistoryViewGrid => 'Grid';
@override
- String get optionsTitle => 'Options';
+ String get optionsTitle => '옵션';
@override
- String get optionsPrimaryProvider => 'Primary Provider';
+ String get optionsPrimaryProvider => '기본 제공자';
@override
- String get optionsPrimaryProviderSubtitle =>
- 'Service used when searching by track name.';
+ String get optionsPrimaryProviderSubtitle => '음반 이름으로 검색할 때 사용되는 서비스';
@override
String optionsUsingExtension(String extensionName) {
- return 'Using extension: $extensionName';
+ return '확장 기능을 사용: $extensionName';
}
@override
- String get optionsSwitchBack =>
- 'Tap Deezer or Spotify to switch back from extension';
+ String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
@override
- String get optionsAutoFallback => 'Auto Fallback';
+ String get optionsAutoFallback => '자동 재시도';
@override
String get optionsAutoFallbackSubtitle => '다운로드가 실패한 경우, 다른 서비스로 재시도';
@override
- String get optionsUseExtensionProviders => 'Use Extension Providers';
+ String get optionsUseExtensionProviders => '확장 기능 사용';
@override
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
@override
- String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
+ String get optionsUseExtensionProvidersOff => '기본으로 제공되는 기능만 사용';
@override
- String get optionsEmbedLyrics => 'Embed Lyrics';
+ String get optionsEmbedLyrics => '가사 삽입';
@override
- String get optionsEmbedLyricsSubtitle =>
- 'Embed synced lyrics into FLAC files';
+ String get optionsEmbedLyricsSubtitle => 'FLAC 파일에 동기화된 가사를 삽입합니다';
@override
- String get optionsMaxQualityCover => 'Max Quality Cover';
+ String get optionsMaxQualityCover => '고품질 커버 이미지';
@override
- String get optionsMaxQualityCoverSubtitle =>
- 'Download highest resolution cover art';
+ String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
@override
- String get optionsConcurrentDownloads => 'Concurrent Downloads';
+ String get optionsConcurrentDownloads => '동시 다운로드';
@override
- String get optionsConcurrentSequential => 'Sequential (1 at a time)';
+ String get optionsConcurrentSequential => '순차 다운로드 (한 번에 하나)';
@override
String optionsConcurrentParallel(int count) {
- return '$count parallel downloads';
+ return '$count개 동시 다운로드';
}
@override
- String get optionsConcurrentWarning =>
- 'Parallel downloads may trigger rate limiting';
+ String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다';
@override
- String get optionsExtensionStore => 'Extension Store';
+ String get optionsExtensionStore => '확장 기능 스토어';
@override
- String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
+ String get optionsExtensionStoreSubtitle => '탐색 메뉴에 스토어 탭 표시';
@override
- String get optionsCheckUpdates => 'Check for Updates';
+ String get optionsCheckUpdates => '업데이트 확인';
@override
- String get optionsCheckUpdatesSubtitle =>
- 'Notify when new version is available';
+ String get optionsCheckUpdatesSubtitle => '새로운 버전이 출시되면 알림';
@override
- String get optionsUpdateChannel => 'Update Channel';
+ String get optionsUpdateChannel => '업데이트 채널';
@override
- String get optionsUpdateChannelStable => 'Stable releases only';
+ String get optionsUpdateChannelStable => '안정적인 버전만 수령';
@override
- String get optionsUpdateChannelPreview => 'Get preview releases';
+ String get optionsUpdateChannelPreview => '미리보기 버전을 수령';
@override
- String get optionsUpdateChannelWarning =>
- 'Preview may contain bugs or incomplete features';
+ String get optionsUpdateChannelWarning => '미리보기 버전은 불안정할 수 있습니다';
@override
- String get optionsClearHistory => 'Clear Download History';
+ String get optionsClearHistory => '다운로드 기록 삭제';
@override
- String get optionsClearHistorySubtitle =>
- 'Remove all downloaded tracks from history';
+ String get optionsClearHistorySubtitle => '기록에서 모든 다운로드 음반을 제거합니다';
@override
- String get optionsDetailedLogging => 'Detailed Logging';
+ String get optionsDetailedLogging => '상세 로깅';
@override
- String get optionsDetailedLoggingOn => 'Detailed logs are being recorded';
+ String get optionsDetailedLoggingOn => '상세한 로그가 기록되고 있습니다';
@override
- String get optionsDetailedLoggingOff => 'Enable for bug reports';
+ String get optionsDetailedLoggingOff => '버그 신고를 위한 기능입니다';
@override
- String get optionsSpotifyCredentials => 'Spotify Credentials';
+ String get optionsSpotifyCredentials => 'Spotify 자격 증명';
@override
String optionsSpotifyCredentialsConfigured(String clientId) {
@@ -219,21 +210,21 @@ class AppLocalizationsKo extends AppLocalizations {
}
@override
- String get optionsSpotifyCredentialsRequired => 'Required - tap to configure';
+ String get optionsSpotifyCredentialsRequired => '탭하여 설정';
@override
String get optionsSpotifyWarning =>
- 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
+ 'Spotify는 사용자 고유의 API 자격 증명을 요구합니다. developer.spotify.com에서 무료로 발급받으세요';
@override
String get optionsSpotifyDeprecationWarning =>
- 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
+ 'Spotify API 변경으로 인해 Spotify 검색 기능은 2026년 3월 3일부터 더 이상 지원되지 않습니다. Deezer로 전환해 주세요';
@override
- String get extensionsTitle => 'Extensions';
+ String get extensionsTitle => '확장 기능';
@override
- String get extensionsDisabled => 'Disabled';
+ String get extensionsDisabled => '비활성화';
@override
String extensionsVersion(String version) {
@@ -246,80 +237,79 @@ class AppLocalizationsKo extends AppLocalizations {
}
@override
- String get extensionsUninstall => 'Uninstall';
+ String get extensionsUninstall => '삭제';
@override
- String get storeTitle => 'Extension Store';
+ String get storeTitle => '확장 기능 스토어';
@override
- String get storeSearch => 'Search extensions...';
+ String get storeSearch => '확장 기능 검색';
@override
- String get storeInstall => 'Install';
+ String get storeInstall => '설치';
@override
- String get storeInstalled => 'Installed';
+ String get storeInstalled => '설치됨';
@override
- String get storeUpdate => 'Update';
+ String get storeUpdate => '업데이트';
@override
- String get aboutTitle => 'About';
+ String get aboutTitle => '정보';
@override
- String get aboutContributors => 'Contributors';
+ String get aboutContributors => '기여자';
@override
- String get aboutMobileDeveloper => 'Mobile version developer';
+ String get aboutMobileDeveloper => '모바일 버전 개발자';
@override
- String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
+ String get aboutOriginalCreator => '오리지널 SpotiFLAC 제작자';
@override
- String get aboutLogoArtist =>
- 'The talented artist who created our beautiful app logo!';
+ String get aboutLogoArtist => '아름다운 로고를 만들어주신 재능 있는 아티스트!';
@override
- String get aboutTranslators => 'Translators';
+ String get aboutTranslators => '번역가들';
@override
- String get aboutSpecialThanks => 'Special Thanks';
+ String get aboutSpecialThanks => '특별 감사';
@override
- String get aboutLinks => 'Links';
+ String get aboutLinks => '바로가기';
@override
String get aboutMobileSource => 'Mobile source code';
@override
- String get aboutPCSource => 'PC source code';
+ String get aboutPCSource => 'PC 소스 코드';
@override
- String get aboutReportIssue => 'Report an issue';
+ String get aboutReportIssue => '문제 신고';
@override
- String get aboutReportIssueSubtitle => 'Report any problems you encounter';
+ String get aboutReportIssueSubtitle => '발생하는 모든 문제를 신고하여 주세요.';
@override
- String get aboutFeatureRequest => 'Feature request';
+ String get aboutFeatureRequest => '기능 요청';
@override
- String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+ String get aboutFeatureRequestSubtitle => '앱의 새로운 기능을 제안하여 주세요.';
@override
String get aboutTelegramChannel => 'Telegram Channel';
@override
- String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+ String get aboutTelegramChannelSubtitle => '공지 및 업데이트 안내';
@override
String get aboutTelegramChat => 'Telegram Community';
@override
- String get aboutTelegramChatSubtitle => 'Chat with other users';
+ String get aboutTelegramChatSubtitle => '다른 이용자와 소통';
@override
- String get aboutSocial => 'Social';
+ String get aboutSocial => '소셜';
@override
String get aboutApp => 'App';
@@ -329,244 +319,242 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutBinimumDesc =>
- 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
+ 'QQDL 및 HiFi API 개발자입니다. 이 API가 없었다면 Tidal 다운로드는 불가능했을 것입니다!';
@override
- String get aboutSachinsenalDesc =>
- 'The original HiFi project creator. The foundation of Tidal integration!';
+ String get aboutSachinsenalDesc => '최초의 하이파이 프로젝트 창시자. 타이달 연동의 기반을 마련한 사람!';
@override
String get aboutSjdonadoDesc =>
- 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
+ 'I Don\'t Have Spotify(IDHS) 개발자입니다. 위급 상황 발생 시 해결해 주는 대체 링크 해결 도구를 만들었습니다!';
@override
String get aboutDabMusic => 'DAB Music';
@override
String get aboutDabMusicDesc =>
- 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
+ '최고의 Qobuz 스트리밍 API입니다. 이 API가 없었다면 고해상도 다운로드는 불가능했을 겁니다!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
- 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
+ 'Tidal Hi-Res FLAC 스트리밍 엔드포인트. 무손실 음원 재생의 핵심 요소!';
@override
String get aboutAppDescription =>
- 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
+ 'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.';
@override
- String get artistAlbums => 'Albums';
+ String get artistAlbums => '앨범';
@override
- String get artistSingles => 'Singles & EPs';
+ String get artistSingles => '싱글 및 EP';
@override
- String get artistCompilations => 'Compilations';
+ String get artistCompilations => '편집';
@override
- String get artistPopular => 'Popular';
+ String get artistPopular => '인기순';
@override
String artistMonthlyListeners(String count) {
- return '$count monthly listeners';
+ return '월간 청취자: $count';
}
@override
- String get trackMetadataService => 'Service';
+ String get trackMetadataService => '제공업체';
@override
- String get trackMetadataPlay => 'Play';
+ String get trackMetadataPlay => '재생';
@override
- String get trackMetadataShare => 'Share';
+ String get trackMetadataShare => '공유';
@override
- String get trackMetadataDelete => 'Delete';
+ String get trackMetadataDelete => '삭제';
@override
- String get setupGrantPermission => 'Grant Permission';
+ String get setupGrantPermission => '권한을 제공해 주세요.';
@override
- String get setupSkip => 'Skip for now';
+ String get setupSkip => '다음에 할래요';
@override
- String get setupStorageAccessRequired => 'Storage Access Required';
+ String get setupStorageAccessRequired => '스토리지 접근 권한 필요';
@override
String get setupStorageAccessMessageAndroid11 =>
- 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.';
+ 'Android 11 이상 버전에서는 선택한 다운로드 폴더에 파일을 저장하려면 \"모든 파일 접근\" 권한이 필요합니다.';
@override
- String get setupOpenSettings => 'Open Settings';
+ String get setupOpenSettings => '설정으로 이동';
@override
String get setupPermissionDeniedMessage =>
- 'Permission denied. Please grant all permissions to continue.';
+ '권한이 거부되었습니다. 계속하려면 모든 권한을 허용해 주세요.';
@override
String setupPermissionRequired(String permissionType) {
- return '$permissionType Permission Required';
+ return '$permissionType 권한 필요';
}
@override
String setupPermissionRequiredMessage(String permissionType) {
- return '$permissionType permission is required for the best experience. You can change this later in Settings.';
+ return '최상의 사용 경험을 위해 $permissionType 권한이 필요합니다. 설정에서 나중에 변경할 수 있습니다.';
}
@override
- String get setupUseDefaultFolder => 'Use Default Folder?';
+ String get setupUseDefaultFolder => '기본 폴더를 사용하시겠습니까?';
@override
- String get setupNoFolderSelected =>
- 'No folder selected. Would you like to use the default Music folder?';
+ String get setupNoFolderSelected => '선택된 폴더가 없습니다. 기본 음악 폴더를 사용하시겠습니까?';
@override
- String get setupUseDefault => 'Use Default';
+ String get setupUseDefault => '기본값 사용';
@override
- String get setupDownloadLocationTitle => 'Download Location';
+ String get setupDownloadLocationTitle => '다운로드 경로';
@override
String get setupDownloadLocationIosMessage =>
- 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.';
+ 'iOS에서는 다운로드한 파일이 앱의 문서 폴더에 저장됩니다. 파일 앱을 통해 해당 파일에 접근할 수 있습니다.';
@override
- String get setupAppDocumentsFolder => 'App Documents Folder';
+ String get setupAppDocumentsFolder => '앱 문서 폴더';
@override
- String get setupAppDocumentsFolderSubtitle =>
- 'Recommended - accessible via Files app';
+ String get setupAppDocumentsFolderSubtitle => '권장 사항 - 파일 앱을 통해 접근 가능';
@override
- String get setupChooseFromFiles => 'Choose from Files';
+ String get setupChooseFromFiles => '파일 탐색기에서 선택';
@override
- String get setupChooseFromFilesSubtitle => 'Select iCloud or other location';
+ String get setupChooseFromFilesSubtitle => 'iCloud 또는 다른 위치를 선택하세요';
@override
String get setupIosEmptyFolderWarning =>
- 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
+ 'iOS 제한 사항: 빈 폴더는 선택할 수 없습니다. 파일이 하나 이상 있는 폴더를 선택하세요.';
@override
String get setupIcloudNotSupported =>
- 'iCloud Drive is not supported. Please use the app Documents folder.';
+ 'iCloud Drive는 지원되지 않습니다. 앱의 문서 폴더를 사용해 주세요.';
@override
- String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
+ String get setupDownloadInFlac => 'Spotify 음악을 FLAC 형식으로 다운로드하세요.';
@override
- String get setupStorageGranted => 'Storage Permission Granted!';
+ String get setupStorageGranted => '저장소 접근 권한이 부여되었습니다!';
@override
- String get setupStorageRequired => 'Storage Permission Required';
+ String get setupStorageRequired => '저장소 접근 권한이 필요합니다.';
@override
String get setupStorageDescription =>
- 'SpotiFLAC needs storage permission to save your downloaded music files.';
+ 'SpotiFLAC은 다운로드한 음악 파일을 저장하기 위해 저장소 접근 권한이 필요합니다.';
@override
- String get setupNotificationGranted => 'Notification Permission Granted!';
+ String get setupNotificationGranted => '알림 권한이 부여되었습니다!';
@override
- String get setupNotificationEnable => 'Enable Notifications';
+ String get setupNotificationEnable => '알림 활성화';
@override
- String get setupFolderChoose => 'Choose Download Folder';
+ String get setupFolderChoose => '다운로드 폴더를 선택하세요';
@override
- String get setupFolderDescription =>
- 'Select a folder where your downloaded music will be saved.';
+ String get setupFolderDescription => '다운로드한 음악 파일이 저장될 폴더를 선택하세요.';
@override
- String get setupSelectFolder => 'Select Folder';
+ String get setupSelectFolder => '폴더 선택';
@override
- String get setupEnableNotifications => 'Enable Notifications';
+ String get setupEnableNotifications => '알림 활성화';
@override
String get setupNotificationBackgroundDescription =>
- 'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
+ '알림으로 다운로드 진행 상황을 확인하세요. 앱이 백그라운드에서 실행 중일 때 다운로드 상태와 완료 여부를 확인할 수 있습니다.';
@override
- String get setupSkipForNow => 'Skip for now';
+ String get setupSkipForNow => '다음에 할래요.';
@override
- String get setupNext => 'Next';
+ String get setupNext => '다음';
@override
- String get setupGetStarted => 'Get Started';
+ String get setupGetStarted => '시작하기';
@override
String get setupAllowAccessToManageFiles =>
- 'Please enable \"Allow access to manage all files\" in the next screen.';
+ '다음 화면에서 \"모든 파일 관리 권한 허용\"을 활성화해 주세요.';
@override
- String get dialogCancel => 'Cancel';
+ String get dialogCancel => '취소';
@override
- String get dialogSave => 'Save';
+ String get dialogSave => '저장';
@override
- String get dialogDelete => 'Delete';
+ String get dialogDelete => '삭제';
@override
- String get dialogRetry => 'Retry';
+ String get dialogRetry => '재시도';
@override
- String get dialogClear => 'Clear';
+ String get dialogClear => '지우기';
@override
- String get dialogDone => 'Done';
+ String get dialogDone => '완료';
@override
- String get dialogImport => 'Import';
+ String get dialogImport => '불러오기';
@override
- String get dialogDiscard => 'Discard';
+ String get dialogDownload => 'Download';
@override
- String get dialogRemove => 'Remove';
+ String get dialogDiscard => '취소';
@override
- String get dialogUninstall => 'Uninstall';
+ String get dialogRemove => '제거';
@override
- String get dialogDiscardChanges => 'Discard Changes?';
+ String get dialogUninstall => '삭제';
@override
- String get dialogUnsavedChanges =>
- 'You have unsaved changes. Do you want to discard them?';
+ String get dialogDiscardChanges => '변경사항 취소';
@override
- String get dialogClearAll => 'Clear All';
+ String get dialogUnsavedChanges => '저장되지 않은 변경 사항이 있습니다. 삭제하시겠습니까?';
@override
- String get dialogRemoveExtension => 'Remove Extension';
+ String get dialogClearAll => '모두 제거:';
+
+ @override
+ String get dialogRemoveExtension => '확장 프로그램 제거';
@override
String get dialogRemoveExtensionMessage =>
- 'Are you sure you want to remove this extension? This cannot be undone.';
+ '이 확장 프로그램을 정말로 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.';
@override
- String get dialogUninstallExtension => 'Uninstall Extension?';
+ String get dialogUninstallExtension => '확장 프로그램을 제거하시겠습니까?';
@override
String dialogUninstallExtensionMessage(String extensionName) {
- return 'Are you sure you want to remove $extensionName?';
+ return '$extensionName을 정말로 삭제하시겠습니까?';
}
@override
- String get dialogClearHistoryTitle => 'Clear History';
+ String get dialogClearHistoryTitle => '기록 삭제';
@override
String get dialogClearHistoryMessage =>
- 'Are you sure you want to clear all download history? This cannot be undone.';
+ '다운로드 기록을 모두 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.';
@override
- String get dialogDeleteSelectedTitle => 'Delete Selected';
+ String get dialogDeleteSelectedTitle => '선택한 항목 삭제';
@override
String dialogDeleteSelectedMessage(int count) {
@@ -576,50 +564,50 @@ class AppLocalizationsKo extends AppLocalizations {
other: 'tracks',
one: 'track',
);
- return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.';
+ return '기록에서 $count $_temp0를 삭제하시겠습니까?';
}
@override
- String get dialogImportPlaylistTitle => 'Import Playlist';
+ String get dialogImportPlaylistTitle => '재생 목록 가져오기';
@override
String dialogImportPlaylistMessage(int count) {
- return 'Found $count tracks in CSV. Add them to download queue?';
+ return 'CSV 파일에서 $count개의 트랙을 찾았습니다. 다운로드 대기열에 추가하시겠습니까?';
}
@override
String csvImportTracks(int count) {
- return '$count tracks from CSV';
+ return 'CSV 파일의 트랙: $count';
}
@override
String snackbarAddedToQueue(String trackName) {
- return 'Added \"$trackName\" to queue';
+ return '\"$trackName\"(을)를 대기열에 추가했습니다.';
}
@override
String snackbarAddedTracksToQueue(int count) {
- return 'Added $count tracks to queue';
+ return '대기열에 $count개의 트랙을 추가했습니다.';
}
@override
String snackbarAlreadyDownloaded(String trackName) {
- return '\"$trackName\" already downloaded';
+ return '\"$trackName\"(은)는 이미 다운로드되었습니다.';
}
@override
String snackbarAlreadyInLibrary(String trackName) {
- return '\"$trackName\" already exists in your library';
+ return '라이브러리에 \"$trackName\"(은)는 이미 존재합니다.';
}
@override
- String get snackbarHistoryCleared => 'History cleared';
+ String get snackbarHistoryCleared => '기록 삭제됨';
@override
- String get snackbarCredentialsSaved => 'Credentials saved';
+ String get snackbarCredentialsSaved => '자격 증명이 저장되었습니다.';
@override
- String get snackbarCredentialsCleared => 'Credentials cleared';
+ String get snackbarCredentialsCleared => '자격 증명이 제거되었습니다.';
@override
String snackbarDeletedTracks(int count) {
@@ -629,136 +617,152 @@ class AppLocalizationsKo extends AppLocalizations {
other: 'tracks',
one: 'track',
);
- return 'Deleted $count $_temp0';
+ return '$count$_temp0 제거됨';
}
@override
String snackbarCannotOpenFile(String error) {
- return 'Cannot open file: $error';
+ return '파일을 열 수 없습니다: $error';
}
@override
- String get snackbarFillAllFields => 'Please fill all fields';
+ String get snackbarFillAllFields => '모든 항목을 입력해 주세요.';
@override
String get snackbarViewQueue => 'View Queue';
@override
String snackbarUrlCopied(String platform) {
- return '$platform URL copied to clipboard';
+ return '$platform 링크가 클립보드에 저장됨';
}
@override
- String get snackbarFileNotFound => 'File not found';
+ String get snackbarFileNotFound => '파일을 찾을 수 없음';
@override
- String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file';
+ String get snackbarSelectExtFile => '.spotiflac-ext 확장자 파일을 선택';
@override
- String get snackbarProviderPrioritySaved => 'Provider priority saved';
+ String get snackbarProviderPrioritySaved => '제공자 우선순위 저장됨';
@override
- String get snackbarMetadataProviderSaved =>
- 'Metadata provider priority saved';
+ String get snackbarMetadataProviderSaved => '메타데이터 제공자 우선순위 저장됨';
@override
String snackbarExtensionInstalled(String extensionName) {
- return '$extensionName installed.';
+ return '$extensionName(이)가 설치됨';
}
@override
String snackbarExtensionUpdated(String extensionName) {
- return '$extensionName updated.';
+ return '$extensionName(이)가 설치됨.';
}
@override
- String get snackbarFailedToInstall => 'Failed to install extension';
+ String get snackbarFailedToInstall => '확장 프로그램 설치 실패';
@override
- String get snackbarFailedToUpdate => 'Failed to update extension';
+ String get snackbarFailedToUpdate => '확장 프로그램 업데이트 실패';
@override
String get errorRateLimited => 'Rate Limited';
@override
- String get errorRateLimitedMessage =>
- 'Too many requests. Please wait a moment before searching again.';
+ String get errorRateLimitedMessage => '요청이 너무 많습니다. 잠시 후 다시 검색해 주세요.';
@override
- String get errorNoTracksFound => 'No tracks found';
+ String get errorNoTracksFound => '트랙을 찾을 수 없습니다';
+
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
@override
String errorMissingExtensionSource(String item) {
- return 'Cannot load $item: missing extension source';
+ return '확장 소스가 누락되어, $item(을)를 로드할 수 없습니다';
}
@override
- String get actionPause => 'Pause';
+ String get actionPause => '멈추기';
@override
- String get actionResume => 'Resume';
+ String get actionResume => '재시작';
@override
- String get actionCancel => 'Cancel';
+ String get actionCancel => '취소';
@override
- String get actionSelectAll => 'Select All';
+ String get actionSelectAll => '모두 선택';
@override
- String get actionDeselect => 'Deselect';
+ String get actionDeselect => '선택 해제';
@override
- String get actionRemoveCredentials => 'Remove Credentials';
+ String get actionRemoveCredentials => '자격 증명 제거';
@override
- String get actionSaveCredentials => 'Save Credentials';
+ String get actionSaveCredentials => '자격 증명 저장';
@override
String selectionSelected(int count) {
- return '$count selected';
+ return '$count개 선택됨';
}
@override
- String get selectionAllSelected => 'All tracks selected';
+ String get selectionAllSelected => '모든 트랙 선택됨';
@override
- String get selectionSelectToDelete => 'Select tracks to delete';
+ String get selectionSelectToDelete => '삭제할 트랙을 선택';
@override
String progressFetchingMetadata(int current, int total) {
- return 'Fetching metadata... $current/$total';
+ return '메타데이터 가져오는 중... $current/$total';
}
@override
- String get progressReadingCsv => 'Reading CSV...';
+ String get progressReadingCsv => 'CSV 파일을 읽는 중...';
@override
- String get searchSongs => 'Songs';
+ String get searchSongs => '곡들';
@override
- String get searchArtists => 'Artists';
+ String get searchArtists => '아티스트들';
@override
- String get searchAlbums => 'Albums';
+ String get searchAlbums => '앨범들';
@override
- String get searchPlaylists => 'Playlists';
+ String get searchPlaylists => '재생목록들';
@override
- String get tooltipPlay => 'Play';
+ String get tooltipPlay => '재생';
@override
- String get filenameFormat => 'Filename Format';
+ String get filenameFormat => '';
@override
- String get filenameShowAdvancedTags => 'Show advanced tags';
+ String get filenameShowAdvancedTags => '고급 태그 표시';
@override
String get filenameShowAdvancedTagsDescription =>
- 'Enable formatted tags for track padding and date patterns';
+ '트랙 패딩 및 날짜 패턴에 대한 서식 있는 태그를 활성화합니다.';
@override
- String get folderOrganizationNone => 'No organization';
+ String get folderOrganizationNone => '정리하지 않음';
+
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -877,116 +881,114 @@ class AppLocalizationsKo extends AppLocalizations {
String get logShareLogs => 'Share logs';
@override
- String get logClearLogs => 'Clear logs';
+ String get logClearLogs => '로그 제거';
@override
- String get logClearLogsTitle => 'Clear Logs';
+ String get logClearLogsTitle => '로그 제거';
@override
- String get logClearLogsMessage => 'Are you sure you want to clear all logs?';
+ String get logClearLogsMessage => '모든 로그를 삭제하시겠습니까?';
@override
- String get logFilterBySeverity => 'Filter logs by severity';
+ String get logFilterBySeverity => '심각성에 따라 로그 분류';
@override
- String get logNoLogsYet => 'No logs yet';
+ String get logNoLogsYet => '어떠한 로그도 없음';
@override
- String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app';
+ String get logNoLogsYetSubtitle => '앱을 사용하는 동안 로그가 여기에 표시됩니다.';
@override
String logEntriesFiltered(int count) {
- return 'Entries ($count filtered)';
+ return '($count filtered)개 항목 필터링';
}
@override
String logEntries(int count) {
- return 'Entries ($count)';
+ return '항목 수: ($count)';
}
@override
- String get credentialsTitle => 'Spotify Credentials';
+ String get credentialsTitle => 'Spotify 자격 증명';
@override
String get credentialsDescription =>
- 'Enter your Client ID and Secret to use your own Spotify application quota.';
+ 'Spotify 애플리케이션 할당량을 사용하려면 클라이언트 ID와 비밀키를 입력하세요.';
@override
String get credentialsClientId => 'Client ID';
@override
- String get credentialsClientIdHint => 'Paste Client ID';
+ String get credentialsClientIdHint => 'Client ID를 붙여넣으세요';
@override
- String get credentialsClientSecret => 'Client Secret';
+ String get credentialsClientSecret => '비밀키';
@override
- String get credentialsClientSecretHint => 'Paste Client Secret';
+ String get credentialsClientSecretHint => '비밀키를 붙여넣으세요';
@override
- String get channelStable => 'Stable';
+ String get channelStable => '안정';
@override
- String get channelPreview => 'Preview';
+ String get channelPreview => '베타';
@override
- String get sectionSearchSource => 'Search Source';
+ String get sectionSearchSource => '검색 소스';
@override
- String get sectionDownload => 'Download';
+ String get sectionDownload => '다운로드';
@override
- String get sectionPerformance => 'Performance';
+ String get sectionPerformance => '성능';
@override
- String get sectionApp => 'App';
+ String get sectionApp => '앱';
@override
- String get sectionData => 'Data';
+ String get sectionData => '데이터';
@override
String get sectionDebug => 'Debug';
@override
- String get sectionService => 'Service';
+ String get sectionService => '서비스';
@override
- String get sectionAudioQuality => 'Audio Quality';
+ String get sectionAudioQuality => '오디오 품질';
@override
- String get sectionFileSettings => 'File Settings';
+ String get sectionFileSettings => '파일 설정';
@override
- String get sectionLyrics => 'Lyrics';
+ String get sectionLyrics => '가사';
@override
- String get lyricsMode => 'Lyrics Mode';
+ String get lyricsMode => '가사 설정';
@override
- String get lyricsModeDescription =>
- 'Choose how lyrics are saved with your downloads';
+ String get lyricsModeDescription => '다운로드한 파일에 가사를 저장하는 방법을 선택하세요.';
@override
- String get lyricsModeEmbed => 'Embed in file';
+ String get lyricsModeEmbed => '파일에 포함';
@override
- String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
+ String get lyricsModeEmbedSubtitle => 'FLAC 메타데이터 내에 저장됩니다.';
@override
- String get lyricsModeExternal => 'External .lrc file';
+ String get lyricsModeExternal => '외부 .lrc 파일';
@override
- String get lyricsModeExternalSubtitle =>
- 'Separate .lrc file for players like Samsung Music';
+ String get lyricsModeExternalSubtitle => '삼성 뮤직과 같은 플레이어용 별도 .lrc 파일';
@override
- String get lyricsModeBoth => 'Both';
+ String get lyricsModeBoth => '둘 다';
@override
- String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
+ String get lyricsModeBothSubtitle => '.lrc 파일을 삽입하고 저장합니다.';
@override
- String get sectionColor => 'Color';
+ String get sectionColor => '색상';
@override
String get sectionTheme => 'Theme';
@@ -1176,6 +1178,47 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1326,6 +1369,38 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1632,6 +1707,25 @@ class AppLocalizationsKo extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -1807,7 +1901,8 @@ class AppLocalizationsKo extends AppLocalizations {
'Download music from Spotify, Deezer, or paste any supported URL';
@override
- String get tutorialWelcomeTip2 => 'Tidal, Qobuz 또는 Deezer에서 FLAC 품질 오디오 받기';
+ String get tutorialWelcomeTip2 =>
+ 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2081,6 +2176,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2113,6 +2230,18 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2124,6 +2253,54 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
@override
String get actionCreate => 'Create';
@@ -2318,6 +2495,17 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2340,4 +2528,418 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart
index 81ecfd12..74e93a95 100644
--- a/lib/l10n/app_localizations_nl.dart
+++ b/lib/l10n/app_localizations_nl.dart
@@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutAppDescription =>
- 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
+ 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get artistAlbums => 'Albums';
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogImport => 'Import';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1177,6 +1198,47 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1327,6 +1389,38 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,25 @@ class AppLocalizationsNl extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -1809,7 +1922,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
- 'Krijg FLAC-kwaliteit audio van Tidal, Qobuz of Deezer';
+ 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2083,6 +2196,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2115,6 +2250,18 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2126,6 +2273,54 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
@override
String get actionCreate => 'Create';
@@ -2320,6 +2515,17 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2342,4 +2548,418 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index 24b813f3..b53d2e07 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -525,6 +525,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get dialogImport => 'Import';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Discard';
@@ -688,6 +691,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get errorNoTracksFound => 'No tracks found';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source';
@@ -761,6 +775,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get folderOrganizationNone => 'No organization';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'By Artist';
@@ -1177,6 +1198,47 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get storeClearFilters => 'Clear filters';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@@ -1327,6 +1389,38 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1633,6 +1727,25 @@ class AppLocalizationsPt extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -2083,6 +2196,28 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2092,7 +2227,8 @@ class AppLocalizationsPt extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
- String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
+ String get trackConvertFormatSubtitle =>
+ 'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2115,6 +2251,18 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2126,6 +2274,54 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
@override
String get actionCreate => 'Create';
@@ -2320,6 +2516,17 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2342,6 +2549,420 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart
index 77913294..810b35a9 100644
--- a/lib/l10n/app_localizations_ru.dart
+++ b/lib/l10n/app_localizations_ru.dart
@@ -67,7 +67,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get settingsAbout => 'О программе';
@override
- String get downloadTitle => 'Скачивание';
+ String get downloadTitle => 'Скачать';
@override
String get downloadAskQualitySubtitle =>
@@ -146,11 +146,11 @@ class AppLocalizationsRu extends AppLocalizations {
'Использование только встроенных провайдеров';
@override
- String get optionsEmbedLyrics => 'Вставить текст песни';
+ String get optionsEmbedLyrics => 'Вписать текст песни';
@override
String get optionsEmbedLyricsSubtitle =>
- 'Вставить синхронизированные тексты в FLAC файлы';
+ 'Вписать синхронизированные тексты во FLAC файлы';
@override
String get optionsMaxQualityCover => 'Максимальное качество обложки';
@@ -337,7 +337,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutBinimumDesc =>
- 'Создатель QQDL & HiFi API. Без этого API загрузки Tidal не существовали бы!';
+ 'Создатель QQDL & HiFi API. Без него API загрузки Tidal не существовали бы!';
@override
String get aboutSachinsenalDesc =>
@@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutAppDescription =>
- 'Скачайте треки Spotify в Lossless качестве из Tidal и Qobuz.';
+ 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
@override
String get artistAlbums => 'Альбомы';
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get dialogImport => 'Импорт';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Отменить';
@@ -601,7 +604,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String csvImportTracks(int count) {
- return '$count треков из CSV';
+ return '$count трек(-ов) из CSV';
}
@override
@@ -702,6 +705,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get errorNoTracksFound => 'Треки не найдены';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return 'Невозможно загрузить $item: отсутствует источник расширения';
@@ -766,15 +780,22 @@ class AppLocalizationsRu extends AppLocalizations {
String get filenameFormat => 'Формат имени файла';
@override
- String get filenameShowAdvancedTags => 'Show advanced tags';
+ String get filenameShowAdvancedTags => 'Показать расширенные теги';
@override
String get filenameShowAdvancedTagsDescription =>
- 'Enable formatted tags for track padding and date patterns';
+ 'Включить форматированные теги для отслеживания заполнения и шаблонов дат';
@override
String get folderOrganizationNone => 'Без организации';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'По исполнителю';
@@ -983,7 +1004,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Выберите как сохранить тексты песен при скачивании';
@override
- String get lyricsModeEmbed => 'Вставить в файл';
+ String get lyricsModeEmbed => 'Вписать в файл';
@override
String get lyricsModeEmbedSubtitle => 'Встроить текст в метаданные FLAC';
@@ -999,7 +1020,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get lyricsModeBoth => 'Оба варианта';
@override
- String get lyricsModeBothSubtitle => 'Вставить и сохранить файл .lrc';
+ String get lyricsModeBothSubtitle => 'Вписать и сохранить .lrc файл';
@override
String get sectionColor => 'Цвет';
@@ -1138,7 +1159,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackLyricsLoadFailed => 'Не удалось загрузить текст песни';
@override
- String get trackEmbedLyrics => 'Вставить текст песни';
+ String get trackEmbedLyrics => 'Вписать текст песни';
@override
String get trackLyricsEmbedded => 'Текст успешно добавлен';
@@ -1198,6 +1219,47 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get storeClearFilters => 'Очистить фильтры';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)';
@@ -1352,6 +1414,38 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
@@ -1361,10 +1455,10 @@ class AppLocalizationsRu extends AppLocalizations {
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
- String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
+ String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
@override
- String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
+ String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1383,7 +1477,8 @@ class AppLocalizationsRu extends AppLocalizations {
'Использовать исполнителя альбома для папок';
@override
- String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
+ String get downloadUsePrimaryArtistOnly =>
+ 'Основной исполнитель только для папок';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
@@ -1391,7 +1486,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
- 'Full artist string used for folder name';
+ 'Полная строка исполнителя, используемая для имени папки';
@override
String get downloadSelectQuality => 'Выбор качества';
@@ -1423,7 +1518,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get settingsDownloadNetwork => 'Сеть для скачивания';
@override
- String get settingsDownloadNetworkAny => 'WiFi и мобильная сеть';
+ String get settingsDownloadNetworkAny => 'WiFi и Мобильная сеть';
@override
String get settingsDownloadNetworkWifiOnly => 'Только WiFi';
@@ -1668,6 +1763,25 @@ class AppLocalizationsRu extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Показать при поиске существующих треков';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Действия';
@@ -1712,8 +1826,10 @@ class AppLocalizationsRu extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'tracks',
- one: 'track',
+ other: 'треков',
+ many: 'треков',
+ few: 'трека',
+ one: 'трек',
);
return '$_temp0';
}
@@ -1800,7 +1916,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get libraryFilterQualityCD => 'CD (16 бит)';
@override
- String get libraryFilterQualityLossy => 'С потерями';
+ String get libraryFilterQualityLossy => 'Lossy';
@override
String get libraryFilterFormat => 'Формат';
@@ -1855,7 +1971,8 @@ class AppLocalizationsRu extends AppLocalizations {
'Скачивайте музыку из Spotify, Deezer, или вставьте любой поддерживаемый URL';
@override
- String get tutorialWelcomeTip2 => 'Скачайте FLAC с Tidal, Qobuz или Deezer';
+ String get tutorialWelcomeTip2 =>
+ 'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -1903,7 +2020,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
- 'Browse the Store tab to discover useful extensions';
+ 'Просмотрите вкладку Магазина, чтобы найти полезные расширения';
@override
String get tutorialExtensionsTip2 =>
@@ -1911,14 +2028,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tutorialExtensionsTip3 =>
- 'Get lyrics, enhanced metadata, and more features';
+ 'Получайте тексты песен, улучшенные метаданные и другие возможности';
@override
String get tutorialSettingsTitle => 'Настройте приложение под себя';
@override
String get tutorialSettingsDesc =>
- 'Personalize the app in Settings to match your preferences.';
+ 'Персонализируйте приложение в Настройках, чтобы оно соответствовало вашим предпочтениям.';
@override
String get tutorialSettingsTip1 =>
@@ -1943,11 +2060,11 @@ class AppLocalizationsRu extends AppLocalizations {
'Пересканировать все файлы, игнорировать кэш';
@override
- String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
+ String get cleanupOrphanedDownloads => 'Очистка отложенных скачиваний';
@override
String get cleanupOrphanedDownloadsSubtitle =>
- 'Remove history entries for files that no longer exist';
+ 'Удалить историю записи для файлов, которых больше не существует';
@override
String cleanupOrphanedDownloadsResult(int count) {
@@ -1955,7 +2072,7 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
- String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
+ String get cleanupOrphanedDownloadsNone => 'Записей без описания не найдено';
@override
String get cacheTitle => 'Хранилище и кэш';
@@ -1965,11 +2082,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheSummarySubtitle =>
- 'Clearing cache will not remove downloaded music files.';
+ 'Очистка кэша не приведет к удалению загруженных музыкальных файлов.';
@override
String cacheEstimatedTotal(String size) {
- return 'Estimated cache usage: $size';
+ return 'Приблизительное использование кэша: $size';
}
@override
@@ -1983,42 +2100,42 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheAppDirectoryDesc =>
- 'HTTP responses, WebView data, and other temporary app data.';
+ 'HTTP-ответы, данные WebView и другие временные данные приложения.';
@override
- String get cacheTempDirectory => 'Temporary directory';
+ String get cacheTempDirectory => 'Временная директория';
@override
String get cacheTempDirectoryDesc =>
- 'Temporary files from downloads and audio conversion.';
+ 'Временные файлы из загрузок и аудио конвертации.';
@override
- String get cacheCoverImage => 'Cover image cache';
+ String get cacheCoverImage => 'Кэш обложек';
@override
String get cacheCoverImageDesc =>
- 'Downloaded album and track cover art. Will re-download when viewed.';
+ 'Скачанный альбом и трек обложки. Будет заново скачан после просмотра.';
@override
- String get cacheLibraryCover => 'Library cover cache';
+ String get cacheLibraryCover => 'Кэш обложек библиотеки';
@override
String get cacheLibraryCoverDesc =>
- 'Cover art extracted from local music files. Will re-extract on next scan.';
+ 'Обложка извлечена из локальных музыкальных файлов. Будет повторно извлечено при следующем сканировании.';
@override
- String get cacheExploreFeed => 'Explore feed cache';
+ String get cacheExploreFeed => 'Просмотреть кэш ленты';
@override
String get cacheExploreFeedDesc =>
- 'Explore tab content (new releases, trending). Will refresh on next visit.';
+ 'Изучите содержимое вкладки (новые релизы, тренды). Они обновятся при следующем посещении.';
@override
- String get cacheTrackLookup => 'Track lookup cache';
+ String get cacheTrackLookup => 'Отслеживать кэш поиска';
@override
String get cacheTrackLookupDesc =>
- 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
+ 'Поиск ID трека в Spotify/Deezer. Очистка может замедлить следующие несколько поисков.';
@override
String get cacheCleanupUnusedDesc =>
@@ -2039,7 +2156,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheEntries(int count) {
- return '$count entries';
+ return '$count записей';
}
@override
@@ -2052,7 +2169,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheClearConfirmMessage(String target) {
- return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
+ return 'Это очистит кэш для $target. Загруженные музыкальные файлы не будут удалены.';
}
@override
@@ -2074,7 +2191,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
- return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
+ return 'Очистка завершена: $downloadCount потерянных загрузок, $libraryCount отсутствующих записей в библиотеке';
}
@override
@@ -2094,14 +2211,14 @@ class AppLocalizationsRu extends AppLocalizations {
'Получить и сохранить текст песни в формате .lrc';
@override
- String get trackSaveLyricsProgress => 'Saving lyrics...';
+ String get trackSaveLyricsProgress => 'Сохранение текста...';
@override
- String get trackReEnrich => 'Re-enrich';
+ String get trackReEnrich => 'Обновить';
@override
String get trackReEnrichOnlineSubtitle =>
- 'Search metadata online and embed into file';
+ 'Поиск в сети метаданных и встраивание в файл';
@override
String get trackEditMetadata => 'Редактировать метаданные';
@@ -2120,40 +2237,62 @@ class AppLocalizationsRu extends AppLocalizations {
}
@override
- String get trackReEnrichProgress => 'Re-enriching metadata...';
+ String get trackReEnrichProgress => 'Обновление метаданных...';
@override
String get trackReEnrichSearching => 'Поиск метаданных в сети...';
@override
- String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
+ String get trackReEnrichSuccess => 'Метаданные успешно обновлены';
@override
String get trackReEnrichFfmpegFailed =>
'Ошибка встраивания метаданных FFmpeg';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Ошибка: $error';
}
@override
- String get trackConvertFormat => 'Convert Format';
+ String get trackConvertFormat => 'Переконвертировать формат';
@override
- String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
+ String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus';
@override
- String get trackConvertTitle => 'Convert Audio';
+ String get trackConvertTitle => 'Конвертировать аудио';
@override
- String get trackConvertTargetFormat => 'Target Format';
+ String get trackConvertTargetFormat => 'Целевой формат';
@override
- String get trackConvertBitrate => 'Bitrate';
+ String get trackConvertBitrate => 'Битрейт';
@override
- String get trackConvertConfirmTitle => 'Confirm Conversion';
+ String get trackConvertConfirmTitle => 'Подтвердить конвертацию';
@override
String trackConvertConfirmMessage(
@@ -2161,177 +2300,241 @@ class AppLocalizationsRu extends AppLocalizations {
String targetFormat,
String bitrate,
) {
- return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
+ return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
}
@override
- String get trackConvertConverting => 'Converting audio...';
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
+ @override
+ String get trackConvertConverting => 'Конвертация аудио...';
@override
String trackConvertSuccess(String format) {
- return 'Converted to $format successfully';
+ return 'Успешно конвертировано в $format';
}
@override
- String get trackConvertFailed => 'Conversion failed';
+ String get trackConvertFailed => 'Ошибка конвертации';
@override
- String get actionCreate => 'Create';
+ String get cueSplitTitle => 'Split CUE Sheet';
@override
- String get collectionFoldersTitle => 'My folders';
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
- String get collectionWishlist => 'Wishlist';
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
@override
- String get collectionLoved => 'Loved';
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
@override
- String get collectionPlaylists => 'Playlists';
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
@override
- String get collectionPlaylist => 'Playlist';
+ String get cueSplitConfirmTitle => 'Split CUE Album';
@override
- String get collectionAddToPlaylist => 'Add to playlist';
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
@override
- String get collectionCreatePlaylist => 'Create playlist';
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
@override
- String get collectionNoPlaylistsYet => 'No playlists yet';
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
+ @override
+ String get actionCreate => 'Создать';
+
+ @override
+ String get collectionFoldersTitle => 'Мои папки';
+
+ @override
+ String get collectionWishlist => 'Список желаемого';
+
+ @override
+ String get collectionLoved => 'Любимые';
+
+ @override
+ String get collectionPlaylists => 'Плейлисты';
+
+ @override
+ String get collectionPlaylist => 'Плейлист';
+
+ @override
+ String get collectionAddToPlaylist => 'Добавить в плейлист';
+
+ @override
+ String get collectionCreatePlaylist => 'Создать плейлист';
+
+ @override
+ String get collectionNoPlaylistsYet => 'Плейлисты отсутствуют';
@override
String get collectionNoPlaylistsSubtitle =>
- 'Create a playlist to start categorizing tracks';
+ 'Создайте плейлист, чтобы начать классифицировать треки';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: '$count tracks',
- one: '1 track',
+ other: '$count треков',
+ many: '$count треков',
+ few: '$count трека',
+ one: '$count трек',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
- return 'Added to \"$playlistName\"';
+ return 'Добавлено в \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
- return 'Already in \"$playlistName\"';
+ return 'Уже в \"$playlistName\"';
}
@override
- String get collectionPlaylistCreated => 'Playlist created';
+ String get collectionPlaylistCreated => 'Плейлист создан';
@override
- String get collectionPlaylistNameHint => 'Playlist name';
+ String get collectionPlaylistNameHint => 'Название плейлиста';
@override
- String get collectionPlaylistNameRequired => 'Playlist name is required';
+ String get collectionPlaylistNameRequired => 'Имя плейлиста обязательно';
@override
- String get collectionRenamePlaylist => 'Rename playlist';
+ String get collectionRenamePlaylist => 'Переименовать плейлист';
@override
- String get collectionDeletePlaylist => 'Delete playlist';
+ String get collectionDeletePlaylist => 'Удалить плейлист';
@override
String collectionDeletePlaylistMessage(String playlistName) {
- return 'Delete \"$playlistName\" and all tracks inside it?';
+ return 'Удалить \"$playlistName\" и все треки внутри него?';
}
@override
- String get collectionPlaylistDeleted => 'Playlist deleted';
+ String get collectionPlaylistDeleted => 'Плейлист удалён';
@override
- String get collectionPlaylistRenamed => 'Playlist renamed';
+ String get collectionPlaylistRenamed => 'Плейлист переименован';
@override
- String get collectionWishlistEmptyTitle => 'Wishlist is empty';
+ String get collectionWishlistEmptyTitle => 'Список желаний пуст';
@override
String get collectionWishlistEmptySubtitle =>
- 'Tap + on tracks to save what you want to download later';
+ 'Нажмите + на треках, чтобы сохранить то, что вы хотите скачать позже';
@override
- String get collectionLovedEmptyTitle => 'Loved folder is empty';
+ String get collectionLovedEmptyTitle => 'Папка Любимые пуста';
@override
String get collectionLovedEmptySubtitle =>
- 'Tap love on tracks to keep your favorites';
+ 'Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные';
@override
- String get collectionPlaylistEmptyTitle => 'Playlist is empty';
+ String get collectionPlaylistEmptyTitle => 'Плейлист пуст';
@override
String get collectionPlaylistEmptySubtitle =>
- 'Long-press + on any track to add it here';
+ 'Удерживайте + на любом треке, чтобы добавить его сюда';
@override
- String get collectionRemoveFromPlaylist => 'Remove from playlist';
+ String get collectionRemoveFromPlaylist => 'Удалить из плейлиста';
@override
- String get collectionRemoveFromFolder => 'Remove from folder';
+ String get collectionRemoveFromFolder => 'Убрать из папки';
@override
String collectionRemoved(String trackName) {
- return '\"$trackName\" removed';
+ return '\"$trackName\" удалён';
}
@override
String collectionAddedToLoved(String trackName) {
- return '\"$trackName\" added to Loved';
+ return '\"$trackName\" добавлен в Любимые';
}
@override
String collectionRemovedFromLoved(String trackName) {
- return '\"$trackName\" removed from Loved';
+ return '\"$trackName\" удалено из Любимых';
}
@override
String collectionAddedToWishlist(String trackName) {
- return '\"$trackName\" added to Wishlist';
+ return '\"$trackName\" добавлен в список желаний';
}
@override
String collectionRemovedFromWishlist(String trackName) {
- return '\"$trackName\" removed from Wishlist';
+ return '\"$trackName\" удалён из списка желаний';
}
@override
- String get trackOptionAddToLoved => 'Add to Loved';
+ String get trackOptionAddToLoved => 'Добавить в Любимое';
@override
- String get trackOptionRemoveFromLoved => 'Remove from Loved';
+ String get trackOptionRemoveFromLoved => 'Исключить из Любимых';
@override
- String get trackOptionAddToWishlist => 'Add to Wishlist';
+ String get trackOptionAddToWishlist => 'Добавить в список желаний';
@override
- String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
+ String get trackOptionRemoveFromWishlist => 'Удалить из списка желаний';
@override
- String get collectionPlaylistChangeCover => 'Change cover image';
+ String get collectionPlaylistChangeCover => 'Изменить обложку';
@override
- String get collectionPlaylistRemoveCover => 'Remove cover image';
+ String get collectionPlaylistRemoveCover => 'Удалить обложку';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'tracks',
- one: 'track',
+ other: 'треков',
+ many: 'треков',
+ few: 'трека',
+ one: 'трек',
);
- return 'Share $count $_temp0';
+ return 'Отправить $count $_temp0';
}
@override
@@ -2342,17 +2545,19 @@ class AppLocalizationsRu extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
- other: 'tracks',
- one: 'track',
+ other: 'треков',
+ many: 'треков',
+ few: 'трека',
+ one: 'трек',
);
- return 'Convert $count $_temp0';
+ return 'Конвертировать $count $_temp0';
}
@override
- String get selectionConvertNoConvertible => 'No convertible tracks selected';
+ String get selectionConvertNoConvertible => 'Не выбраны конвертируемые треки';
@override
- String get selectionBatchConvertConfirmTitle => 'Batch Convert';
+ String get selectionBatchConvertConfirmTitle => 'Пакетная конвертация';
@override
String selectionBatchConvertConfirmMessage(
@@ -2369,14 +2574,25 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
- return 'Converting $current of $total...';
+ return 'Конвертация $current из $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
- return 'Converted $success of $total tracks to $format';
+ return 'Конвертировано $success треков $total в $format';
}
@override
@@ -2386,9 +2602,423 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
- 'Artist folders use Album Artist when available';
+ 'Для папок исполнителей используется исполнитель альбома, если он указан';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Папки исполнителя используют только трек исполнителя';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart
index 53034492..63a54638 100644
--- a/lib/l10n/app_localizations_tr.dart
+++ b/lib/l10n/app_localizations_tr.dart
@@ -530,6 +530,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get dialogImport => 'İçe aktar';
+ @override
+ String get dialogDownload => 'Download';
+
@override
String get dialogDiscard => 'Vazgeç';
@@ -693,6 +696,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get errorNoTracksFound => 'Parça bulunamadı';
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
@override
String errorMissingExtensionSource(String item) {
return '$item yüklenemedi: Eksik eklenti kaynağı';
@@ -766,6 +780,13 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get folderOrganizationNone => 'Organizasyon yok';
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
@override
String get folderOrganizationByArtist => 'Sanatçıya Göre';
@@ -1188,6 +1209,47 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get storeClearFilters => 'Filtreleri temizle';
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
@override
String get extensionDefaultProvider => 'Varsayılan (Deezer/Spotify)';
@@ -1339,6 +1401,38 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1645,6 +1739,25 @@ class AppLocalizationsTr extends AppLocalizations {
String get libraryShowDuplicateIndicatorSubtitle =>
'Show when searching for existing tracks';
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
@override
String get libraryActions => 'Actions';
@@ -2095,6 +2208,28 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2127,6 +2262,18 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2138,6 +2285,54 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
@override
String get actionCreate => 'Create';
@@ -2332,6 +2527,17 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2354,4 +2560,418 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
}
diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart
index a4fc7e77..31adb1ff 100644
--- a/lib/l10n/app_localizations_zh.dart
+++ b/lib/l10n/app_localizations_zh.dart
@@ -525,6 +525,2967 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get dialogImport => 'Import';
+ @override
+ String get dialogDownload => 'Download';
+
+ @override
+ String get dialogDiscard => 'Discard';
+
+ @override
+ String get dialogRemove => 'Remove';
+
+ @override
+ String get dialogUninstall => 'Uninstall';
+
+ @override
+ String get dialogDiscardChanges => 'Discard Changes?';
+
+ @override
+ String get dialogUnsavedChanges =>
+ 'You have unsaved changes. Do you want to discard them?';
+
+ @override
+ String get dialogClearAll => 'Clear All';
+
+ @override
+ String get dialogRemoveExtension => 'Remove Extension';
+
+ @override
+ String get dialogRemoveExtensionMessage =>
+ 'Are you sure you want to remove this extension? This cannot be undone.';
+
+ @override
+ String get dialogUninstallExtension => 'Uninstall Extension?';
+
+ @override
+ String dialogUninstallExtensionMessage(String extensionName) {
+ return 'Are you sure you want to remove $extensionName?';
+ }
+
+ @override
+ String get dialogClearHistoryTitle => 'Clear History';
+
+ @override
+ String get dialogClearHistoryMessage =>
+ 'Are you sure you want to clear all download history? This cannot be undone.';
+
+ @override
+ String get dialogDeleteSelectedTitle => 'Delete Selected';
+
+ @override
+ String dialogDeleteSelectedMessage(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.';
+ }
+
+ @override
+ String get dialogImportPlaylistTitle => 'Import Playlist';
+
+ @override
+ String dialogImportPlaylistMessage(int count) {
+ return 'Found $count tracks in CSV. Add them to download queue?';
+ }
+
+ @override
+ String csvImportTracks(int count) {
+ return '$count tracks from CSV';
+ }
+
+ @override
+ String snackbarAddedToQueue(String trackName) {
+ return 'Added \"$trackName\" to queue';
+ }
+
+ @override
+ String snackbarAddedTracksToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String snackbarAlreadyDownloaded(String trackName) {
+ return '\"$trackName\" already downloaded';
+ }
+
+ @override
+ String snackbarAlreadyInLibrary(String trackName) {
+ return '\"$trackName\" already exists in your library';
+ }
+
+ @override
+ String get snackbarHistoryCleared => 'History cleared';
+
+ @override
+ String get snackbarCredentialsSaved => 'Credentials saved';
+
+ @override
+ String get snackbarCredentialsCleared => 'Credentials cleared';
+
+ @override
+ String snackbarDeletedTracks(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Deleted $count $_temp0';
+ }
+
+ @override
+ String snackbarCannotOpenFile(String error) {
+ return 'Cannot open file: $error';
+ }
+
+ @override
+ String get snackbarFillAllFields => 'Please fill all fields';
+
+ @override
+ String get snackbarViewQueue => 'View Queue';
+
+ @override
+ String snackbarUrlCopied(String platform) {
+ return '$platform URL copied to clipboard';
+ }
+
+ @override
+ String get snackbarFileNotFound => 'File not found';
+
+ @override
+ String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file';
+
+ @override
+ String get snackbarProviderPrioritySaved => 'Provider priority saved';
+
+ @override
+ String get snackbarMetadataProviderSaved =>
+ 'Metadata provider priority saved';
+
+ @override
+ String snackbarExtensionInstalled(String extensionName) {
+ return '$extensionName installed.';
+ }
+
+ @override
+ String snackbarExtensionUpdated(String extensionName) {
+ return '$extensionName updated.';
+ }
+
+ @override
+ String get snackbarFailedToInstall => 'Failed to install extension';
+
+ @override
+ String get snackbarFailedToUpdate => 'Failed to update extension';
+
+ @override
+ String get errorRateLimited => 'Rate Limited';
+
+ @override
+ String get errorRateLimitedMessage =>
+ 'Too many requests. Please wait a moment before searching again.';
+
+ @override
+ String get errorNoTracksFound => 'No tracks found';
+
+ @override
+ String get errorUrlNotRecognized => 'Link not recognized';
+
+ @override
+ String get errorUrlNotRecognizedMessage =>
+ 'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
+
+ @override
+ String get errorUrlFetchFailed =>
+ 'Failed to load content from this link. Please try again.';
+
+ @override
+ String errorMissingExtensionSource(String item) {
+ return 'Cannot load $item: missing extension source';
+ }
+
+ @override
+ String get actionPause => 'Pause';
+
+ @override
+ String get actionResume => 'Resume';
+
+ @override
+ String get actionCancel => 'Cancel';
+
+ @override
+ String get actionSelectAll => 'Select All';
+
+ @override
+ String get actionDeselect => 'Deselect';
+
+ @override
+ String get actionRemoveCredentials => 'Remove Credentials';
+
+ @override
+ String get actionSaveCredentials => 'Save Credentials';
+
+ @override
+ String selectionSelected(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get selectionAllSelected => 'All tracks selected';
+
+ @override
+ String get selectionSelectToDelete => 'Select tracks to delete';
+
+ @override
+ String progressFetchingMetadata(int current, int total) {
+ return 'Fetching metadata... $current/$total';
+ }
+
+ @override
+ String get progressReadingCsv => 'Reading CSV...';
+
+ @override
+ String get searchSongs => 'Songs';
+
+ @override
+ String get searchArtists => 'Artists';
+
+ @override
+ String get searchAlbums => 'Albums';
+
+ @override
+ String get searchPlaylists => 'Playlists';
+
+ @override
+ String get tooltipPlay => 'Play';
+
+ @override
+ String get filenameFormat => 'Filename Format';
+
+ @override
+ String get filenameShowAdvancedTags => 'Show advanced tags';
+
+ @override
+ String get filenameShowAdvancedTagsDescription =>
+ 'Enable formatted tags for track padding and date patterns';
+
+ @override
+ String get folderOrganizationNone => 'No organization';
+
+ @override
+ String get folderOrganizationByPlaylist => 'By Playlist';
+
+ @override
+ String get folderOrganizationByPlaylistSubtitle =>
+ 'Separate folder for each playlist';
+
+ @override
+ String get folderOrganizationByArtist => 'By Artist';
+
+ @override
+ String get folderOrganizationByAlbum => 'By Album';
+
+ @override
+ String get folderOrganizationByArtistAlbum => 'Artist/Album';
+
+ @override
+ String get folderOrganizationDescription =>
+ 'Organize downloaded files into folders';
+
+ @override
+ String get folderOrganizationNoneSubtitle => 'All files in download folder';
+
+ @override
+ String get folderOrganizationByArtistSubtitle =>
+ 'Separate folder for each artist';
+
+ @override
+ String get folderOrganizationByAlbumSubtitle =>
+ 'Separate folder for each album';
+
+ @override
+ String get folderOrganizationByArtistAlbumSubtitle =>
+ 'Nested folders for artist and album';
+
+ @override
+ String get updateAvailable => 'Update Available';
+
+ @override
+ String get updateLater => 'Later';
+
+ @override
+ String get updateStartingDownload => 'Starting download...';
+
+ @override
+ String get updateDownloadFailed => 'Download failed';
+
+ @override
+ String get updateFailedMessage => 'Failed to download update';
+
+ @override
+ String get updateNewVersionReady => 'A new version is ready';
+
+ @override
+ String get updateCurrent => 'Current';
+
+ @override
+ String get updateNew => 'New';
+
+ @override
+ String get updateDownloading => 'Downloading...';
+
+ @override
+ String get updateWhatsNew => 'What\'s New';
+
+ @override
+ String get updateDownloadInstall => 'Download & Install';
+
+ @override
+ String get updateDontRemind => 'Don\'t remind';
+
+ @override
+ String get providerPriorityTitle => 'Provider Priority';
+
+ @override
+ String get providerPriorityDescription =>
+ 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.';
+
+ @override
+ String get providerPriorityInfo =>
+ 'If a track is not available on the first provider, the app will automatically try the next one.';
+
+ @override
+ String get providerBuiltIn => 'Built-in';
+
+ @override
+ String get providerExtension => 'Extension';
+
+ @override
+ String get metadataProviderPriorityTitle => 'Metadata Priority';
+
+ @override
+ String get metadataProviderPriorityDescription =>
+ 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.';
+
+ @override
+ String get metadataProviderPriorityInfo =>
+ 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.';
+
+ @override
+ String get metadataNoRateLimits => 'No rate limits';
+
+ @override
+ String get metadataMayRateLimit => 'May rate limit';
+
+ @override
+ String get logTitle => 'Logs';
+
+ @override
+ String get logCopied => 'Logs copied to clipboard';
+
+ @override
+ String get logSearchHint => 'Search logs...';
+
+ @override
+ String get logFilterLevel => 'Level';
+
+ @override
+ String get logFilterSection => 'Filter';
+
+ @override
+ String get logShareLogs => 'Share logs';
+
+ @override
+ String get logClearLogs => 'Clear logs';
+
+ @override
+ String get logClearLogsTitle => 'Clear Logs';
+
+ @override
+ String get logClearLogsMessage => 'Are you sure you want to clear all logs?';
+
+ @override
+ String get logFilterBySeverity => 'Filter logs by severity';
+
+ @override
+ String get logNoLogsYet => 'No logs yet';
+
+ @override
+ String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app';
+
+ @override
+ String logEntriesFiltered(int count) {
+ return 'Entries ($count filtered)';
+ }
+
+ @override
+ String logEntries(int count) {
+ return 'Entries ($count)';
+ }
+
+ @override
+ String get credentialsTitle => 'Spotify Credentials';
+
+ @override
+ String get credentialsDescription =>
+ 'Enter your Client ID and Secret to use your own Spotify application quota.';
+
+ @override
+ String get credentialsClientId => 'Client ID';
+
+ @override
+ String get credentialsClientIdHint => 'Paste Client ID';
+
+ @override
+ String get credentialsClientSecret => 'Client Secret';
+
+ @override
+ String get credentialsClientSecretHint => 'Paste Client Secret';
+
+ @override
+ String get channelStable => 'Stable';
+
+ @override
+ String get channelPreview => 'Preview';
+
+ @override
+ String get sectionSearchSource => 'Search Source';
+
+ @override
+ String get sectionDownload => 'Download';
+
+ @override
+ String get sectionPerformance => 'Performance';
+
+ @override
+ String get sectionApp => 'App';
+
+ @override
+ String get sectionData => 'Data';
+
+ @override
+ String get sectionDebug => 'Debug';
+
+ @override
+ String get sectionService => 'Service';
+
+ @override
+ String get sectionAudioQuality => 'Audio Quality';
+
+ @override
+ String get sectionFileSettings => 'File Settings';
+
+ @override
+ String get sectionLyrics => 'Lyrics';
+
+ @override
+ String get lyricsMode => 'Lyrics Mode';
+
+ @override
+ String get lyricsModeDescription =>
+ 'Choose how lyrics are saved with your downloads';
+
+ @override
+ String get lyricsModeEmbed => 'Embed in file';
+
+ @override
+ String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
+
+ @override
+ String get lyricsModeExternal => 'External .lrc file';
+
+ @override
+ String get lyricsModeExternalSubtitle =>
+ 'Separate .lrc file for players like Samsung Music';
+
+ @override
+ String get lyricsModeBoth => 'Both';
+
+ @override
+ String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
+
+ @override
+ String get sectionColor => 'Color';
+
+ @override
+ String get sectionTheme => 'Theme';
+
+ @override
+ String get sectionLayout => 'Layout';
+
+ @override
+ String get sectionLanguage => 'Language';
+
+ @override
+ String get appearanceLanguage => 'App Language';
+
+ @override
+ String get settingsAppearanceSubtitle => 'Theme, colors, display';
+
+ @override
+ String get settingsDownloadSubtitle => 'Service, quality, filename format';
+
+ @override
+ String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
+
+ @override
+ String get settingsExtensionsSubtitle => 'Manage download providers';
+
+ @override
+ String get settingsLogsSubtitle => 'View app logs for debugging';
+
+ @override
+ String get loadingSharedLink => 'Loading shared link...';
+
+ @override
+ String get pressBackAgainToExit => 'Press back again to exit';
+
+ @override
+ String downloadAllCount(int count) {
+ return 'Download All ($count)';
+ }
+
+ @override
+ String tracksCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get trackCopyFilePath => 'Copy file path';
+
+ @override
+ String get trackRemoveFromDevice => 'Remove from device';
+
+ @override
+ String get trackLoadLyrics => 'Load Lyrics';
+
+ @override
+ String get trackMetadata => 'Metadata';
+
+ @override
+ String get trackFileInfo => 'File Info';
+
+ @override
+ String get trackLyrics => 'Lyrics';
+
+ @override
+ String get trackFileNotFound => 'File not found';
+
+ @override
+ String get trackOpenInDeezer => 'Open in Deezer';
+
+ @override
+ String get trackOpenInSpotify => 'Open in Spotify';
+
+ @override
+ String get trackTrackName => 'Track name';
+
+ @override
+ String get trackArtist => 'Artist';
+
+ @override
+ String get trackAlbumArtist => 'Album artist';
+
+ @override
+ String get trackAlbum => 'Album';
+
+ @override
+ String get trackTrackNumber => 'Track number';
+
+ @override
+ String get trackDiscNumber => 'Disc number';
+
+ @override
+ String get trackDuration => 'Duration';
+
+ @override
+ String get trackAudioQuality => 'Audio quality';
+
+ @override
+ String get trackReleaseDate => 'Release date';
+
+ @override
+ String get trackGenre => 'Genre';
+
+ @override
+ String get trackLabel => 'Label';
+
+ @override
+ String get trackCopyright => 'Copyright';
+
+ @override
+ String get trackDownloaded => 'Downloaded';
+
+ @override
+ String get trackCopyLyrics => 'Copy lyrics';
+
+ @override
+ String get trackLyricsNotAvailable => 'Lyrics not available for this track';
+
+ @override
+ String get trackLyricsTimeout => 'Request timed out. Try again later.';
+
+ @override
+ String get trackLyricsLoadFailed => 'Failed to load lyrics';
+
+ @override
+ String get trackEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get trackLyricsEmbedded => 'Lyrics embedded successfully';
+
+ @override
+ String get trackInstrumental => 'Instrumental track';
+
+ @override
+ String get trackCopiedToClipboard => 'Copied to clipboard';
+
+ @override
+ String get trackDeleteConfirmTitle => 'Remove from device?';
+
+ @override
+ String get trackDeleteConfirmMessage =>
+ 'This will permanently delete the downloaded file and remove it from your history.';
+
+ @override
+ String get dateToday => 'Today';
+
+ @override
+ String get dateYesterday => 'Yesterday';
+
+ @override
+ String dateDaysAgo(int count) {
+ return '$count days ago';
+ }
+
+ @override
+ String dateWeeksAgo(int count) {
+ return '$count weeks ago';
+ }
+
+ @override
+ String dateMonthsAgo(int count) {
+ return '$count months ago';
+ }
+
+ @override
+ String get storeFilterAll => 'All';
+
+ @override
+ String get storeFilterMetadata => 'Metadata';
+
+ @override
+ String get storeFilterDownload => 'Download';
+
+ @override
+ String get storeFilterUtility => 'Utility';
+
+ @override
+ String get storeFilterLyrics => 'Lyrics';
+
+ @override
+ String get storeFilterIntegration => 'Integration';
+
+ @override
+ String get storeClearFilters => 'Clear filters';
+
+ @override
+ String get storeAddRepoTitle => 'Add Extension Repository';
+
+ @override
+ String get storeAddRepoDescription =>
+ 'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
+
+ @override
+ String get storeRepoUrlLabel => 'Repository URL';
+
+ @override
+ String get storeRepoUrlHint => 'https://github.com/user/repo';
+
+ @override
+ String get storeRepoUrlHelper =>
+ 'e.g. https://github.com/user/extensions-repo';
+
+ @override
+ String get storeAddRepoButton => 'Add Repository';
+
+ @override
+ String get storeChangeRepoTooltip => 'Change repository';
+
+ @override
+ String get storeRepoDialogTitle => 'Extension Repository';
+
+ @override
+ String get storeRepoDialogCurrent => 'Current repository:';
+
+ @override
+ String get storeNewRepoUrlLabel => 'New Repository URL';
+
+ @override
+ String get storeLoadError => 'Failed to load store';
+
+ @override
+ String get storeEmptyNoExtensions => 'No extensions available';
+
+ @override
+ String get storeEmptyNoResults => 'No extensions found';
+
+ @override
+ String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
+
+ @override
+ String get extensionDefaultProviderSubtitle => 'Use built-in search';
+
+ @override
+ String get extensionAuthor => 'Author';
+
+ @override
+ String get extensionId => 'ID';
+
+ @override
+ String get extensionError => 'Error';
+
+ @override
+ String get extensionCapabilities => 'Capabilities';
+
+ @override
+ String get extensionMetadataProvider => 'Metadata Provider';
+
+ @override
+ String get extensionDownloadProvider => 'Download Provider';
+
+ @override
+ String get extensionLyricsProvider => 'Lyrics Provider';
+
+ @override
+ String get extensionUrlHandler => 'URL Handler';
+
+ @override
+ String get extensionQualityOptions => 'Quality Options';
+
+ @override
+ String get extensionPostProcessingHooks => 'Post-Processing Hooks';
+
+ @override
+ String get extensionPermissions => 'Permissions';
+
+ @override
+ String get extensionSettings => 'Settings';
+
+ @override
+ String get extensionRemoveButton => 'Remove Extension';
+
+ @override
+ String get extensionUpdated => 'Updated';
+
+ @override
+ String get extensionMinAppVersion => 'Min App Version';
+
+ @override
+ String get extensionCustomTrackMatching => 'Custom Track Matching';
+
+ @override
+ String get extensionPostProcessing => 'Post-Processing';
+
+ @override
+ String extensionHooksAvailable(int count) {
+ return '$count hook(s) available';
+ }
+
+ @override
+ String extensionPatternsCount(int count) {
+ return '$count pattern(s)';
+ }
+
+ @override
+ String extensionStrategy(String strategy) {
+ return 'Strategy: $strategy';
+ }
+
+ @override
+ String get extensionsProviderPrioritySection => 'Provider Priority';
+
+ @override
+ String get extensionsInstalledSection => 'Installed Extensions';
+
+ @override
+ String get extensionsNoExtensions => 'No extensions installed';
+
+ @override
+ String get extensionsNoExtensionsSubtitle =>
+ 'Install .spotiflac-ext files to add new providers';
+
+ @override
+ String get extensionsInstallButton => 'Install Extension';
+
+ @override
+ String get extensionsInfoTip =>
+ 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.';
+
+ @override
+ String get extensionsInstalledSuccess => 'Extension installed successfully';
+
+ @override
+ String get extensionsDownloadPriority => 'Download Priority';
+
+ @override
+ String get extensionsDownloadPrioritySubtitle => 'Set download service order';
+
+ @override
+ String get extensionsNoDownloadProvider =>
+ 'No extensions with download provider';
+
+ @override
+ String get extensionsMetadataPriority => 'Metadata Priority';
+
+ @override
+ String get extensionsMetadataPrioritySubtitle =>
+ 'Set search & metadata source order';
+
+ @override
+ String get extensionsNoMetadataProvider =>
+ 'No extensions with metadata provider';
+
+ @override
+ String get extensionsSearchProvider => 'Search Provider';
+
+ @override
+ String get extensionsNoCustomSearch => 'No extensions with custom search';
+
+ @override
+ String get extensionsSearchProviderDescription =>
+ 'Choose which service to use for searching tracks';
+
+ @override
+ String get extensionsCustomSearch => 'Custom search';
+
+ @override
+ String get extensionsErrorLoading => 'Error loading extension';
+
+ @override
+ String get qualityFlacLossless => 'FLAC Lossless';
+
+ @override
+ String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz';
+
+ @override
+ String get qualityHiResFlac => 'Hi-Res FLAC';
+
+ @override
+ String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz';
+
+ @override
+ String get qualityHiResFlacMax => 'Hi-Res FLAC Max';
+
+ @override
+ String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
+
+ @override
+ String get downloadLossy320 => 'Lossy 320kbps';
+
+ @override
+ String get downloadLossyFormat => 'Lossy Format';
+
+ @override
+ String get downloadLossy320Format => 'Lossy 320kbps Format';
+
+ @override
+ String get downloadLossy320FormatDesc =>
+ 'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
+
+ @override
+ String get downloadLossyMp3 => 'MP3 320kbps';
+
+ @override
+ String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
+
+ @override
+ String get downloadLossyOpus256 => 'Opus 256kbps';
+
+ @override
+ String get downloadLossyOpus256Subtitle =>
+ 'Best quality Opus, ~8MB per track';
+
+ @override
+ String get downloadLossyOpus128 => 'Opus 128kbps';
+
+ @override
+ String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
+
+ @override
+ 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';
+
+ @override
+ String get downloadDirectory => 'Download Directory';
+
+ @override
+ String get downloadSeparateSinglesFolder => 'Separate Singles Folder';
+
+ @override
+ String get downloadAlbumFolderStructure => 'Album Folder Structure';
+
+ @override
+ String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
+
+ @override
+ String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
+
+ @override
+ String get downloadUsePrimaryArtistOnlyEnabled =>
+ 'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
+
+ @override
+ String get downloadUsePrimaryArtistOnlyDisabled =>
+ 'Full artist string used for folder name';
+
+ @override
+ String get downloadSelectQuality => 'Select Quality';
+
+ @override
+ String get downloadFrom => 'Download From';
+
+ @override
+ String get appearanceAmoledDark => 'AMOLED Dark';
+
+ @override
+ String get appearanceAmoledDarkSubtitle => 'Pure black background';
+
+ @override
+ String get queueClearAll => 'Clear All';
+
+ @override
+ String get queueClearAllMessage =>
+ 'Are you sure you want to clear all downloads?';
+
+ @override
+ String get settingsAutoExportFailed => 'Auto-export failed downloads';
+
+ @override
+ String get settingsAutoExportFailedSubtitle =>
+ 'Save failed downloads to TXT file automatically';
+
+ @override
+ String get settingsDownloadNetwork => 'Download Network';
+
+ @override
+ String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
+
+ @override
+ String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
+
+ @override
+ String get settingsDownloadNetworkSubtitle =>
+ 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
+
+ @override
+ String get albumFolderArtistAlbum => 'Artist / Album';
+
+ @override
+ String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
+
+ @override
+ String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
+
+ @override
+ String get albumFolderArtistYearAlbumSubtitle =>
+ 'Albums/Artist Name/[2005] Album Name/';
+
+ @override
+ String get albumFolderAlbumOnly => 'Album Only';
+
+ @override
+ String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/';
+
+ @override
+ String get albumFolderYearAlbum => '[Year] Album';
+
+ @override
+ String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
+
+ @override
+ String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
+
+ @override
+ String get albumFolderArtistAlbumSinglesSubtitle =>
+ 'Artist/Album/ and Artist/Singles/';
+
+ @override
+ String get downloadedAlbumDeleteSelected => 'Delete Selected';
+
+ @override
+ String downloadedAlbumDeleteMessage(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.';
+ }
+
+ @override
+ String downloadedAlbumSelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get downloadedAlbumAllSelected => 'All tracks selected';
+
+ @override
+ String get downloadedAlbumTapToSelect => 'Tap tracks to select';
+
+ @override
+ String downloadedAlbumDeleteCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Delete $count $_temp0';
+ }
+
+ @override
+ String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
+
+ @override
+ String downloadedAlbumDiscHeader(int discNumber) {
+ return 'Disc $discNumber';
+ }
+
+ @override
+ String get recentTypeArtist => 'Artist';
+
+ @override
+ String get recentTypeAlbum => 'Album';
+
+ @override
+ String get recentTypeSong => 'Song';
+
+ @override
+ String get recentTypePlaylist => 'Playlist';
+
+ @override
+ String get recentEmpty => 'No recent items yet';
+
+ @override
+ String get recentShowAllDownloads => 'Show All Downloads';
+
+ @override
+ String recentPlaylistInfo(String name) {
+ return 'Playlist: $name';
+ }
+
+ @override
+ String get discographyDownload => 'Download Discography';
+
+ @override
+ String get discographyDownloadAll => 'Download All';
+
+ @override
+ String discographyDownloadAllSubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount releases';
+ }
+
+ @override
+ String get discographyAlbumsOnly => 'Albums Only';
+
+ @override
+ String discographyAlbumsOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount albums';
+ }
+
+ @override
+ String get discographySinglesOnly => 'Singles & EPs Only';
+
+ @override
+ String discographySinglesOnlySubtitle(int count, int albumCount) {
+ return '$count tracks from $albumCount singles';
+ }
+
+ @override
+ String get discographySelectAlbums => 'Select Albums...';
+
+ @override
+ String get discographySelectAlbumsSubtitle =>
+ 'Choose specific albums or singles';
+
+ @override
+ String get discographyFetchingTracks => 'Fetching tracks...';
+
+ @override
+ String discographyFetchingAlbum(int current, int total) {
+ return 'Fetching $current of $total...';
+ }
+
+ @override
+ String discographySelectedCount(int count) {
+ return '$count selected';
+ }
+
+ @override
+ String get discographyDownloadSelected => 'Download Selected';
+
+ @override
+ String discographyAddedToQueue(int count) {
+ return 'Added $count tracks to queue';
+ }
+
+ @override
+ String discographySkippedDownloaded(int added, int skipped) {
+ return '$added added, $skipped already downloaded';
+ }
+
+ @override
+ String get discographyNoAlbums => 'No albums available';
+
+ @override
+ String get discographyFailedToFetch => 'Failed to fetch some albums';
+
+ @override
+ String get sectionStorageAccess => 'Storage Access';
+
+ @override
+ String get allFilesAccess => 'All Files Access';
+
+ @override
+ String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
+
+ @override
+ String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
+
+ @override
+ String get allFilesAccessDescription =>
+ 'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
+
+ @override
+ String get allFilesAccessDeniedMessage =>
+ 'Permission was denied. Please enable \'All files access\' manually in system settings.';
+
+ @override
+ String get allFilesAccessDisabledMessage =>
+ 'All Files Access disabled. The app will use limited storage access.';
+
+ @override
+ String get settingsLocalLibrary => 'Local Library';
+
+ @override
+ String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
+
+ @override
+ String get settingsCache => 'Storage & Cache';
+
+ @override
+ String get settingsCacheSubtitle => 'View size and clear cached data';
+
+ @override
+ String get libraryTitle => 'Local Library';
+
+ @override
+ String get libraryScanSettings => 'Scan Settings';
+
+ @override
+ String get libraryEnableLocalLibrary => 'Enable Local Library';
+
+ @override
+ String get libraryEnableLocalLibrarySubtitle =>
+ 'Scan and track your existing music';
+
+ @override
+ String get libraryFolder => 'Library Folder';
+
+ @override
+ String get libraryFolderHint => 'Tap to select folder';
+
+ @override
+ String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
+
+ @override
+ String get libraryShowDuplicateIndicatorSubtitle =>
+ 'Show when searching for existing tracks';
+
+ @override
+ String get libraryAutoScan => 'Auto Scan';
+
+ @override
+ String get libraryAutoScanSubtitle =>
+ 'Automatically scan your library for new files';
+
+ @override
+ String get libraryAutoScanOff => 'Off';
+
+ @override
+ String get libraryAutoScanOnOpen => 'Every app open';
+
+ @override
+ String get libraryAutoScanDaily => 'Daily';
+
+ @override
+ String get libraryAutoScanWeekly => 'Weekly';
+
+ @override
+ String get libraryActions => 'Actions';
+
+ @override
+ String get libraryScan => 'Scan Library';
+
+ @override
+ String get libraryScanSubtitle => 'Scan for audio files';
+
+ @override
+ String get libraryScanSelectFolderFirst => 'Select a folder first';
+
+ @override
+ String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
+
+ @override
+ String get libraryCleanupMissingFilesSubtitle =>
+ 'Remove entries for files that no longer exist';
+
+ @override
+ String get libraryClear => 'Clear Library';
+
+ @override
+ String get libraryClearSubtitle => 'Remove all scanned tracks';
+
+ @override
+ String get libraryClearConfirmTitle => 'Clear Library';
+
+ @override
+ String get libraryClearConfirmMessage =>
+ 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
+
+ @override
+ String get libraryAbout => 'About Local Library';
+
+ @override
+ String get libraryAboutDescription =>
+ 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
+
+ @override
+ String libraryTracksUnit(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String libraryLastScanned(String time) {
+ return 'Last scanned: $time';
+ }
+
+ @override
+ String get libraryLastScannedNever => 'Never';
+
+ @override
+ String get libraryScanning => 'Scanning...';
+
+ @override
+ String libraryScanProgress(String progress, int total) {
+ return '$progress% of $total files';
+ }
+
+ @override
+ String get libraryInLibrary => 'In Library';
+
+ @override
+ String libraryRemovedMissingFiles(int count) {
+ return 'Removed $count missing files from library';
+ }
+
+ @override
+ String get libraryCleared => 'Library cleared';
+
+ @override
+ String get libraryStorageAccessRequired => 'Storage Access Required';
+
+ @override
+ String get libraryStorageAccessMessage =>
+ 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
+
+ @override
+ String get libraryFolderNotExist => 'Selected folder does not exist';
+
+ @override
+ String get librarySourceDownloaded => 'Downloaded';
+
+ @override
+ String get librarySourceLocal => 'Local';
+
+ @override
+ String get libraryFilterAll => 'All';
+
+ @override
+ String get libraryFilterDownloaded => 'Downloaded';
+
+ @override
+ String get libraryFilterLocal => 'Local';
+
+ @override
+ String get libraryFilterTitle => 'Filters';
+
+ @override
+ String get libraryFilterReset => 'Reset';
+
+ @override
+ String get libraryFilterApply => 'Apply';
+
+ @override
+ String get libraryFilterSource => 'Source';
+
+ @override
+ String get libraryFilterQuality => 'Quality';
+
+ @override
+ String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
+
+ @override
+ String get libraryFilterQualityCD => 'CD (16bit)';
+
+ @override
+ String get libraryFilterQualityLossy => 'Lossy';
+
+ @override
+ String get libraryFilterFormat => 'Format';
+
+ @override
+ String get libraryFilterSort => 'Sort';
+
+ @override
+ String get libraryFilterSortLatest => 'Latest';
+
+ @override
+ String get libraryFilterSortOldest => 'Oldest';
+
+ @override
+ String get timeJustNow => 'Just now';
+
+ @override
+ String timeMinutesAgo(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count minutes ago',
+ one: '1 minute ago',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String timeHoursAgo(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count hours ago',
+ one: '1 hour ago',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
+
+ @override
+ String get tutorialWelcomeDesc =>
+ 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
+
+ @override
+ String get tutorialWelcomeTip1 =>
+ 'Download music from Spotify, Deezer, or paste any supported URL';
+
+ @override
+ String get tutorialWelcomeTip2 =>
+ 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
+
+ @override
+ String get tutorialWelcomeTip3 =>
+ 'Automatic metadata, cover art, and lyrics embedding';
+
+ @override
+ String get tutorialSearchTitle => 'Finding Music';
+
+ @override
+ String get tutorialSearchDesc =>
+ 'There are two easy ways to find music you want to download.';
+
+ @override
+ String get tutorialDownloadTitle => 'Downloading Music';
+
+ @override
+ String get tutorialDownloadDesc =>
+ 'Downloading music is simple and fast. Here\'s how it works.';
+
+ @override
+ String get tutorialLibraryTitle => 'Your Library';
+
+ @override
+ String get tutorialLibraryDesc =>
+ 'All your downloaded music is organized in the Library tab.';
+
+ @override
+ String get tutorialLibraryTip1 =>
+ 'View download progress and queue in the Library tab';
+
+ @override
+ String get tutorialLibraryTip2 =>
+ 'Tap any track to play it with your music player';
+
+ @override
+ String get tutorialLibraryTip3 =>
+ 'Switch between list and grid view for better browsing';
+
+ @override
+ String get tutorialExtensionsTitle => 'Extensions';
+
+ @override
+ String get tutorialExtensionsDesc =>
+ 'Extend the app\'s capabilities with community extensions.';
+
+ @override
+ String get tutorialExtensionsTip1 =>
+ 'Browse the Store tab to discover useful extensions';
+
+ @override
+ String get tutorialExtensionsTip2 =>
+ 'Add new download providers or search sources';
+
+ @override
+ String get tutorialExtensionsTip3 =>
+ 'Get lyrics, enhanced metadata, and more features';
+
+ @override
+ String get tutorialSettingsTitle => 'Customize Your Experience';
+
+ @override
+ String get tutorialSettingsDesc =>
+ 'Personalize the app in Settings to match your preferences.';
+
+ @override
+ String get tutorialSettingsTip1 =>
+ 'Change download location and folder organization';
+
+ @override
+ String get tutorialSettingsTip2 =>
+ 'Set default audio quality and format preferences';
+
+ @override
+ String get tutorialSettingsTip3 => 'Customize app theme and appearance';
+
+ @override
+ String get tutorialReadyMessage =>
+ 'You\'re all set! Start downloading your favorite music now.';
+
+ @override
+ String get libraryForceFullScan => 'Force Full Scan';
+
+ @override
+ String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
+
+ @override
+ String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
+
+ @override
+ String get cleanupOrphanedDownloadsSubtitle =>
+ 'Remove history entries for files that no longer exist';
+
+ @override
+ String cleanupOrphanedDownloadsResult(int count) {
+ return 'Removed $count orphaned entries from history';
+ }
+
+ @override
+ String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
+
+ @override
+ String get cacheTitle => 'Storage & Cache';
+
+ @override
+ String get cacheSummaryTitle => 'Cache overview';
+
+ @override
+ String get cacheSummarySubtitle =>
+ 'Clearing cache will not remove downloaded music files.';
+
+ @override
+ String cacheEstimatedTotal(String size) {
+ return 'Estimated cache usage: $size';
+ }
+
+ @override
+ String get cacheSectionStorage => 'Cached Data';
+
+ @override
+ String get cacheSectionMaintenance => 'Maintenance';
+
+ @override
+ String get cacheAppDirectory => 'App cache directory';
+
+ @override
+ String get cacheAppDirectoryDesc =>
+ 'HTTP responses, WebView data, and other temporary app data.';
+
+ @override
+ String get cacheTempDirectory => 'Temporary directory';
+
+ @override
+ String get cacheTempDirectoryDesc =>
+ 'Temporary files from downloads and audio conversion.';
+
+ @override
+ String get cacheCoverImage => 'Cover image cache';
+
+ @override
+ String get cacheCoverImageDesc =>
+ 'Downloaded album and track cover art. Will re-download when viewed.';
+
+ @override
+ String get cacheLibraryCover => 'Library cover cache';
+
+ @override
+ String get cacheLibraryCoverDesc =>
+ 'Cover art extracted from local music files. Will re-extract on next scan.';
+
+ @override
+ String get cacheExploreFeed => 'Explore feed cache';
+
+ @override
+ String get cacheExploreFeedDesc =>
+ 'Explore tab content (new releases, trending). Will refresh on next visit.';
+
+ @override
+ String get cacheTrackLookup => 'Track lookup cache';
+
+ @override
+ String get cacheTrackLookupDesc =>
+ 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
+
+ @override
+ String get cacheCleanupUnusedDesc =>
+ 'Remove orphaned download history and library entries for missing files.';
+
+ @override
+ String get cacheNoData => 'No cached data';
+
+ @override
+ String cacheSizeWithFiles(String size, int count) {
+ return '$size in $count files';
+ }
+
+ @override
+ String cacheSizeOnly(String size) {
+ return '$size';
+ }
+
+ @override
+ String cacheEntries(int count) {
+ return '$count entries';
+ }
+
+ @override
+ String cacheClearSuccess(String target) {
+ return 'Cleared: $target';
+ }
+
+ @override
+ String get cacheClearConfirmTitle => 'Clear cache?';
+
+ @override
+ String cacheClearConfirmMessage(String target) {
+ return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
+ }
+
+ @override
+ String get cacheClearAllConfirmTitle => 'Clear all cache?';
+
+ @override
+ String get cacheClearAllConfirmMessage =>
+ 'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
+
+ @override
+ String get cacheClearAll => 'Clear all cache';
+
+ @override
+ String get cacheCleanupUnused => 'Cleanup unused data';
+
+ @override
+ String get cacheCleanupUnusedSubtitle =>
+ 'Remove orphaned download history and missing library entries';
+
+ @override
+ String cacheCleanupResult(int downloadCount, int libraryCount) {
+ return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
+ }
+
+ @override
+ String get cacheRefreshStats => 'Refresh stats';
+
+ @override
+ String get trackSaveCoverArt => 'Save Cover Art';
+
+ @override
+ String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
+
+ @override
+ String get trackSaveLyrics => 'Save Lyrics (.lrc)';
+
+ @override
+ String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
+
+ @override
+ String get trackSaveLyricsProgress => 'Saving lyrics...';
+
+ @override
+ String get trackReEnrich => 'Re-enrich';
+
+ @override
+ String get trackReEnrichOnlineSubtitle =>
+ 'Search metadata online and embed into file';
+
+ @override
+ String get trackEditMetadata => 'Edit Metadata';
+
+ @override
+ String trackCoverSaved(String fileName) {
+ return 'Cover art saved to $fileName';
+ }
+
+ @override
+ String get trackCoverNoSource => 'No cover art source available';
+
+ @override
+ String trackLyricsSaved(String fileName) {
+ return 'Lyrics saved to $fileName';
+ }
+
+ @override
+ String get trackReEnrichProgress => 'Re-enriching metadata...';
+
+ @override
+ String get trackReEnrichSearching => 'Searching metadata online...';
+
+ @override
+ String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
+
+ @override
+ String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
+
+ @override
+ String get queueFlacAction => 'Queue FLAC';
+
+ @override
+ String queueFlacConfirmMessage(int count) {
+ return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
+ }
+
+ @override
+ String queueFlacFindingProgress(int current, int total) {
+ return 'Finding FLAC matches... ($current/$total)';
+ }
+
+ @override
+ String get queueFlacNoReliableMatches =>
+ 'No reliable online matches found for the selection';
+
+ @override
+ String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
+ return 'Added $addedCount tracks to queue, skipped $skippedCount';
+ }
+
+ @override
+ String trackSaveFailed(String error) {
+ return 'Failed: $error';
+ }
+
+ @override
+ String get trackConvertFormat => 'Convert Format';
+
+ @override
+ String get trackConvertFormatSubtitle =>
+ 'Convert to MP3, Opus, ALAC, or FLAC';
+
+ @override
+ String get trackConvertTitle => 'Convert Audio';
+
+ @override
+ String get trackConvertTargetFormat => 'Target Format';
+
+ @override
+ String get trackConvertBitrate => 'Bitrate';
+
+ @override
+ String get trackConvertConfirmTitle => 'Confirm Conversion';
+
+ @override
+ String trackConvertConfirmMessage(
+ String sourceFormat,
+ String targetFormat,
+ String bitrate,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String trackConvertConfirmMessageLossless(
+ String sourceFormat,
+ String targetFormat,
+ ) {
+ return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
+ }
+
+ @override
+ String get trackConvertLosslessHint =>
+ 'Lossless conversion — no quality loss';
+
+ @override
+ String get trackConvertConverting => 'Converting audio...';
+
+ @override
+ String trackConvertSuccess(String format) {
+ return 'Converted to $format successfully';
+ }
+
+ @override
+ String get trackConvertFailed => 'Conversion failed';
+
+ @override
+ String get cueSplitTitle => 'Split CUE Sheet';
+
+ @override
+ String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
+
+ @override
+ String cueSplitAlbum(String album) {
+ return 'Album: $album';
+ }
+
+ @override
+ String cueSplitArtist(String artist) {
+ return 'Artist: $artist';
+ }
+
+ @override
+ String cueSplitTrackCount(int count) {
+ return '$count tracks';
+ }
+
+ @override
+ String get cueSplitConfirmTitle => 'Split CUE Album';
+
+ @override
+ String cueSplitConfirmMessage(String album, int count) {
+ return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
+ }
+
+ @override
+ String cueSplitSplitting(int current, int total) {
+ return 'Splitting CUE sheet... ($current/$total)';
+ }
+
+ @override
+ String cueSplitSuccess(int count) {
+ return 'Split into $count tracks successfully';
+ }
+
+ @override
+ String get cueSplitFailed => 'CUE split failed';
+
+ @override
+ String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
+
+ @override
+ String get cueSplitButton => 'Split into Tracks';
+
+ @override
+ String get actionCreate => 'Create';
+
+ @override
+ String get collectionFoldersTitle => 'My folders';
+
+ @override
+ String get collectionWishlist => 'Wishlist';
+
+ @override
+ String get collectionLoved => 'Loved';
+
+ @override
+ String get collectionPlaylists => 'Playlists';
+
+ @override
+ String get collectionPlaylist => 'Playlist';
+
+ @override
+ String get collectionAddToPlaylist => 'Add to playlist';
+
+ @override
+ String get collectionCreatePlaylist => 'Create playlist';
+
+ @override
+ String get collectionNoPlaylistsYet => 'No playlists yet';
+
+ @override
+ String get collectionNoPlaylistsSubtitle =>
+ 'Create a playlist to start categorizing tracks';
+
+ @override
+ String collectionPlaylistTracks(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String collectionAddedToPlaylist(String playlistName) {
+ return 'Added to \"$playlistName\"';
+ }
+
+ @override
+ String collectionAlreadyInPlaylist(String playlistName) {
+ return 'Already in \"$playlistName\"';
+ }
+
+ @override
+ String get collectionPlaylistCreated => 'Playlist created';
+
+ @override
+ String get collectionPlaylistNameHint => 'Playlist name';
+
+ @override
+ String get collectionPlaylistNameRequired => 'Playlist name is required';
+
+ @override
+ String get collectionRenamePlaylist => 'Rename playlist';
+
+ @override
+ String get collectionDeletePlaylist => 'Delete playlist';
+
+ @override
+ String collectionDeletePlaylistMessage(String playlistName) {
+ return 'Delete \"$playlistName\" and all tracks inside it?';
+ }
+
+ @override
+ String get collectionPlaylistDeleted => 'Playlist deleted';
+
+ @override
+ String get collectionPlaylistRenamed => 'Playlist renamed';
+
+ @override
+ String get collectionWishlistEmptyTitle => 'Wishlist is empty';
+
+ @override
+ String get collectionWishlistEmptySubtitle =>
+ 'Tap + on tracks to save what you want to download later';
+
+ @override
+ String get collectionLovedEmptyTitle => 'Loved folder is empty';
+
+ @override
+ String get collectionLovedEmptySubtitle =>
+ 'Tap love on tracks to keep your favorites';
+
+ @override
+ String get collectionPlaylistEmptyTitle => 'Playlist is empty';
+
+ @override
+ String get collectionPlaylistEmptySubtitle =>
+ 'Long-press + on any track to add it here';
+
+ @override
+ String get collectionRemoveFromPlaylist => 'Remove from playlist';
+
+ @override
+ String get collectionRemoveFromFolder => 'Remove from folder';
+
+ @override
+ String collectionRemoved(String trackName) {
+ return '\"$trackName\" removed';
+ }
+
+ @override
+ String collectionAddedToLoved(String trackName) {
+ return '\"$trackName\" added to Loved';
+ }
+
+ @override
+ String collectionRemovedFromLoved(String trackName) {
+ return '\"$trackName\" removed from Loved';
+ }
+
+ @override
+ String collectionAddedToWishlist(String trackName) {
+ return '\"$trackName\" added to Wishlist';
+ }
+
+ @override
+ String collectionRemovedFromWishlist(String trackName) {
+ return '\"$trackName\" removed from Wishlist';
+ }
+
+ @override
+ String get trackOptionAddToLoved => 'Add to Loved';
+
+ @override
+ String get trackOptionRemoveFromLoved => 'Remove from Loved';
+
+ @override
+ String get trackOptionAddToWishlist => 'Add to Wishlist';
+
+ @override
+ String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
+
+ @override
+ String get collectionPlaylistChangeCover => 'Change cover image';
+
+ @override
+ String get collectionPlaylistRemoveCover => 'Remove cover image';
+
+ @override
+ String selectionShareCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Share $count $_temp0';
+ }
+
+ @override
+ String get selectionShareNoFiles => 'No shareable files found';
+
+ @override
+ String selectionConvertCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0';
+ }
+
+ @override
+ String get selectionConvertNoConvertible => 'No convertible tracks selected';
+
+ @override
+ String get selectionBatchConvertConfirmTitle => 'Batch Convert';
+
+ @override
+ String selectionBatchConvertConfirmMessage(
+ int count,
+ String format,
+ String bitrate,
+ ) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
+ }
+
+ @override
+ String selectionBatchConvertConfirmMessageLossless(int count, String format) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
+ }
+
+ @override
+ String selectionBatchConvertProgress(int current, int total) {
+ return 'Converting $current of $total...';
+ }
+
+ @override
+ String selectionBatchConvertSuccess(int success, int total, String format) {
+ return 'Converted $success of $total tracks to $format';
+ }
+
+ @override
+ String downloadedAlbumDownloadedCount(int count) {
+ return '$count downloaded';
+ }
+
+ @override
+ String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
+ 'Artist folders use Album Artist when available';
+
+ @override
+ String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
+ 'Artist folders use Track Artist only';
+
+ @override
+ String get lyricsProvidersTitle => 'Lyrics Providers';
+
+ @override
+ String get lyricsProvidersDescription =>
+ 'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
+
+ @override
+ String get lyricsProvidersInfoText =>
+ 'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
+
+ @override
+ String lyricsProvidersEnabledSection(int count) {
+ return 'Enabled ($count)';
+ }
+
+ @override
+ String lyricsProvidersDisabledSection(int count) {
+ return 'Disabled ($count)';
+ }
+
+ @override
+ String get lyricsProvidersAtLeastOne =>
+ 'At least one provider must remain enabled';
+
+ @override
+ String get lyricsProvidersSaved => 'Lyrics provider priority saved';
+
+ @override
+ String get lyricsProvidersDiscardContent =>
+ 'You have unsaved changes that will be lost.';
+
+ @override
+ String get lyricsProviderSpotifyApiDesc =>
+ 'Spotify-sourced synced lyrics via community API';
+
+ @override
+ String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
+
+ @override
+ String get lyricsProviderNeteaseDesc =>
+ 'NetEase Cloud Music (good for Asian songs)';
+
+ @override
+ String get lyricsProviderMusixmatchDesc =>
+ 'Largest lyrics database (multi-language)';
+
+ @override
+ String get lyricsProviderAppleMusicDesc =>
+ 'Word-by-word synced lyrics (via proxy)';
+
+ @override
+ String get lyricsProviderQqMusicDesc =>
+ 'QQ Music (good for Chinese songs, via proxy)';
+
+ @override
+ String get lyricsProviderExtensionDesc => 'Extension provider';
+
+ @override
+ String get safMigrationTitle => 'Storage Update Required';
+
+ @override
+ String get safMigrationMessage1 =>
+ 'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
+
+ @override
+ String get safMigrationMessage2 =>
+ 'Please select your download folder again to switch to the new storage system.';
+
+ @override
+ String get safMigrationSuccess => 'Download folder updated to SAF mode';
+
+ @override
+ String get settingsDonate => 'Donate';
+
+ @override
+ String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
+
+ @override
+ String get tooltipLoveAll => 'Love All';
+
+ @override
+ String get tooltipAddToPlaylist => 'Add to Playlist';
+
+ @override
+ String snackbarRemovedTracksFromLoved(int count) {
+ return 'Removed $count tracks from Loved';
+ }
+
+ @override
+ String snackbarAddedTracksToLoved(int count) {
+ return 'Added $count tracks to Loved';
+ }
+
+ @override
+ String get dialogDownloadAllTitle => 'Download All';
+
+ @override
+ String dialogDownloadAllMessage(int count) {
+ return 'Download $count tracks?';
+ }
+
+ @override
+ String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
+
+ @override
+ String get homeGoToAlbum => 'Go to Album';
+
+ @override
+ String get homeAlbumInfoUnavailable => 'Album info not available';
+
+ @override
+ String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
+
+ @override
+ String get snackbarMetadataSaved => 'Metadata saved successfully';
+
+ @override
+ String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
+
+ @override
+ String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
+
+ @override
+ String snackbarError(String error) {
+ return 'Error: $error';
+ }
+
+ @override
+ String get snackbarNoActionDefined => 'No action defined for this button';
+
+ @override
+ String get noTracksFoundForAlbum => 'No tracks found for this album';
+
+ @override
+ String get downloadLocationSubtitle =>
+ 'Choose storage mode for downloaded files.';
+
+ @override
+ String get storageModeAppFolder => 'App folder (non-SAF)';
+
+ @override
+ String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
+
+ @override
+ String get storageModeSaf => 'SAF folder';
+
+ @override
+ String get storageModeSafSubtitle =>
+ 'Pick folder via Android Storage Access Framework';
+
+ @override
+ String get downloadFilenameDescription =>
+ 'Customize how your files are named.';
+
+ @override
+ String get downloadFilenameInsertTag => 'Tap to insert tag:';
+
+ @override
+ String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
+
+ @override
+ String get downloadSeparateSinglesDisabled => 'All files in same structure';
+
+ @override
+ String get downloadArtistNameFilters => 'Artist Name Filters';
+
+ @override
+ String get downloadCreatePlaylistSourceFolder =>
+ 'Create playlist source folder';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderEnabled =>
+ 'Playlist downloads use Playlist/ plus your normal folder structure.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderDisabled =>
+ 'Playlist downloads use the normal folder structure only.';
+
+ @override
+ String get downloadCreatePlaylistSourceFolderRedundant =>
+ 'By Playlist already places downloads inside a playlist folder.';
+
+ @override
+ String get downloadSongLinkRegion => 'SongLink Region';
+
+ @override
+ String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
+
+ @override
+ String get downloadNetworkCompatibilityModeEnabled =>
+ 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
+
+ @override
+ String get downloadNetworkCompatibilityModeDisabled =>
+ 'Off: strict HTTPS certificate validation (recommended)';
+
+ @override
+ String get downloadSelectServiceToEnable =>
+ 'Select a built-in service to enable';
+
+ @override
+ String get downloadSelectTidalQobuz =>
+ 'Select Tidal or Qobuz above to configure quality';
+
+ @override
+ String get downloadEmbedLyricsDisabled =>
+ 'Disabled while Embed Metadata is turned off';
+
+ @override
+ String get downloadNeteaseIncludeTranslation =>
+ 'Netease: Include Translation';
+
+ @override
+ String get downloadNeteaseIncludeTranslationEnabled =>
+ 'Append translated lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeTranslationDisabled =>
+ 'Use original lyrics only';
+
+ @override
+ String get downloadNeteaseIncludeRomanization =>
+ 'Netease: Include Romanization';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationEnabled =>
+ 'Append romanized lyrics when available';
+
+ @override
+ String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
+
+ @override
+ String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
+
+ @override
+ String get downloadAppleQqMultiPersonEnabled =>
+ 'Enable v1/v2 speaker and [bg:] tags';
+
+ @override
+ String get downloadAppleQqMultiPersonDisabled =>
+ 'Simplified word-by-word formatting';
+
+ @override
+ String get downloadMusixmatchLanguage => 'Musixmatch Language';
+
+ @override
+ String get downloadMusixmatchLanguageAuto => 'Auto (original)';
+
+ @override
+ String get downloadFilterContributing =>
+ 'Filter contributing artists in Album Artist';
+
+ @override
+ String get downloadFilterContributingEnabled =>
+ 'Album Artist metadata uses primary artist only';
+
+ @override
+ String get downloadFilterContributingDisabled =>
+ 'Keep full Album Artist metadata value';
+
+ @override
+ String get downloadProvidersNoneEnabled => 'None enabled';
+
+ @override
+ String get downloadMusixmatchLanguageCode => 'Language code';
+
+ @override
+ String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
+
+ @override
+ String get downloadMusixmatchLanguageDesc =>
+ 'Set preferred language code (example: en, es, ja). Leave empty for auto.';
+
+ @override
+ String get downloadMusixmatchAuto => 'Auto';
+
+ @override
+ String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
+
+ @override
+ String get downloadNetworkWifiOnlySubtitle =>
+ 'Pause downloads on mobile data';
+
+ @override
+ String get downloadSongLinkRegionDesc =>
+ 'Used as userCountry for SongLink API lookup.';
+
+ @override
+ String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
+
+ @override
+ String get cacheRefresh => 'Refresh';
+
+ @override
+ String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
+ String _temp0 = intl.Intl.pluralLogic(
+ trackCount,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ String _temp1 = intl.Intl.pluralLogic(
+ playlistCount,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
+ }
+
+ @override
+ String bulkDownloadPlaylistsButton(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'playlists',
+ one: 'playlist',
+ );
+ return 'Download $count $_temp0';
+ }
+
+ @override
+ String get bulkDownloadSelectPlaylists => 'Select playlists to download';
+
+ @override
+ String get snackbarSelectedPlaylistsEmpty =>
+ 'Selected playlists have no tracks';
+
+ @override
+ String playlistsCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count playlists',
+ one: '1 playlist',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String get editMetadataAutoFill => 'Auto-fill from online';
+
+ @override
+ String get editMetadataAutoFillDesc =>
+ 'Select fields to fill automatically from online metadata';
+
+ @override
+ String get editMetadataAutoFillFetch => 'Fetch & Fill';
+
+ @override
+ String get editMetadataAutoFillSearching => 'Searching online...';
+
+ @override
+ String get editMetadataAutoFillNoResults =>
+ 'No matching metadata found online';
+
+ @override
+ String editMetadataAutoFillDone(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'fields',
+ one: 'field',
+ );
+ return 'Filled $count $_temp0 from online metadata';
+ }
+
+ @override
+ String get editMetadataAutoFillNoneSelected =>
+ 'Select at least one field to auto-fill';
+
+ @override
+ String get editMetadataFieldTitle => 'Title';
+
+ @override
+ String get editMetadataFieldArtist => 'Artist';
+
+ @override
+ String get editMetadataFieldAlbum => 'Album';
+
+ @override
+ String get editMetadataFieldAlbumArtist => 'Album Artist';
+
+ @override
+ String get editMetadataFieldDate => 'Date';
+
+ @override
+ String get editMetadataFieldTrackNum => 'Track #';
+
+ @override
+ String get editMetadataFieldDiscNum => 'Disc #';
+
+ @override
+ String get editMetadataFieldGenre => 'Genre';
+
+ @override
+ String get editMetadataFieldIsrc => 'ISRC';
+
+ @override
+ String get editMetadataFieldLabel => 'Label';
+
+ @override
+ String get editMetadataFieldCopyright => 'Copyright';
+
+ @override
+ String get editMetadataFieldCover => 'Cover Art';
+
+ @override
+ String get editMetadataSelectAll => 'All';
+
+ @override
+ String get editMetadataSelectEmpty => 'Empty only';
+}
+
+/// The translations for Chinese, as used in China (`zh_CN`).
+class AppLocalizationsZhCn extends AppLocalizationsZh {
+ AppLocalizationsZhCn() : super('zh_CN');
+
+ @override
+ String get appName => 'SpotiFLAC';
+
+ @override
+ String get navHome => 'Home';
+
+ @override
+ String get navLibrary => 'Library';
+
+ @override
+ String get navSettings => 'Settings';
+
+ @override
+ String get navStore => 'Store';
+
+ @override
+ String get homeTitle => 'Home';
+
+ @override
+ String get homeSubtitle => 'Paste a Spotify link or search by name';
+
+ @override
+ String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
+
+ @override
+ String get homeRecent => 'Recent';
+
+ @override
+ String get historyFilterAll => 'All';
+
+ @override
+ String get historyFilterAlbums => 'Albums';
+
+ @override
+ String get historyFilterSingles => 'Singles';
+
+ @override
+ String get historySearchHint => 'Search history...';
+
+ @override
+ String get settingsTitle => 'Settings';
+
+ @override
+ String get settingsDownload => 'Download';
+
+ @override
+ String get settingsAppearance => 'Appearance';
+
+ @override
+ String get settingsOptions => 'Options';
+
+ @override
+ String get settingsExtensions => 'Extensions';
+
+ @override
+ String get settingsAbout => 'About';
+
+ @override
+ String get downloadTitle => 'Download';
+
+ @override
+ String get downloadAskQualitySubtitle =>
+ 'Show quality picker for each download';
+
+ @override
+ String get downloadFilenameFormat => 'Filename Format';
+
+ @override
+ String get downloadFolderOrganization => 'Folder Organization';
+
+ @override
+ String get appearanceTitle => 'Appearance';
+
+ @override
+ String get appearanceThemeSystem => 'System';
+
+ @override
+ String get appearanceThemeLight => 'Light';
+
+ @override
+ String get appearanceThemeDark => 'Dark';
+
+ @override
+ String get appearanceDynamicColor => 'Dynamic Color';
+
+ @override
+ String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
+
+ @override
+ String get appearanceHistoryView => 'History View';
+
+ @override
+ String get appearanceHistoryViewList => 'List';
+
+ @override
+ String get appearanceHistoryViewGrid => 'Grid';
+
+ @override
+ String get optionsTitle => 'Options';
+
+ @override
+ String get optionsPrimaryProvider => 'Primary Provider';
+
+ @override
+ String get optionsPrimaryProviderSubtitle =>
+ 'Service used when searching by track name.';
+
+ @override
+ String optionsUsingExtension(String extensionName) {
+ return 'Using extension: $extensionName';
+ }
+
+ @override
+ String get optionsSwitchBack =>
+ 'Tap Deezer or Spotify to switch back from extension';
+
+ @override
+ String get optionsAutoFallback => 'Auto Fallback';
+
+ @override
+ String get optionsAutoFallbackSubtitle =>
+ 'Try other services if download fails';
+
+ @override
+ String get optionsUseExtensionProviders => 'Use Extension Providers';
+
+ @override
+ String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
+
+ @override
+ String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
+
+ @override
+ String get optionsEmbedLyrics => 'Embed Lyrics';
+
+ @override
+ String get optionsEmbedLyricsSubtitle =>
+ 'Embed synced lyrics into FLAC files';
+
+ @override
+ String get optionsMaxQualityCover => 'Max Quality Cover';
+
+ @override
+ String get optionsMaxQualityCoverSubtitle =>
+ 'Download highest resolution cover art';
+
+ @override
+ String get optionsConcurrentDownloads => 'Concurrent Downloads';
+
+ @override
+ String get optionsConcurrentSequential => 'Sequential (1 at a time)';
+
+ @override
+ String optionsConcurrentParallel(int count) {
+ return '$count parallel downloads';
+ }
+
+ @override
+ String get optionsConcurrentWarning =>
+ 'Parallel downloads may trigger rate limiting';
+
+ @override
+ String get optionsExtensionStore => 'Extension Store';
+
+ @override
+ String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
+
+ @override
+ String get optionsCheckUpdates => 'Check for Updates';
+
+ @override
+ String get optionsCheckUpdatesSubtitle =>
+ 'Notify when new version is available';
+
+ @override
+ String get optionsUpdateChannel => 'Update Channel';
+
+ @override
+ String get optionsUpdateChannelStable => 'Stable releases only';
+
+ @override
+ String get optionsUpdateChannelPreview => 'Get preview releases';
+
+ @override
+ String get optionsUpdateChannelWarning =>
+ 'Preview may contain bugs or incomplete features';
+
+ @override
+ String get optionsClearHistory => 'Clear Download History';
+
+ @override
+ String get optionsClearHistorySubtitle =>
+ 'Remove all downloaded tracks from history';
+
+ @override
+ String get optionsDetailedLogging => 'Detailed Logging';
+
+ @override
+ String get optionsDetailedLoggingOn => 'Detailed logs are being recorded';
+
+ @override
+ String get optionsDetailedLoggingOff => 'Enable for bug reports';
+
+ @override
+ String get optionsSpotifyCredentials => 'Spotify Credentials';
+
+ @override
+ String optionsSpotifyCredentialsConfigured(String clientId) {
+ return 'Client ID: $clientId...';
+ }
+
+ @override
+ String get optionsSpotifyCredentialsRequired => 'Required - tap to configure';
+
+ @override
+ String get optionsSpotifyWarning =>
+ 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
+
+ @override
+ String get optionsSpotifyDeprecationWarning =>
+ 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
+
+ @override
+ String get extensionsTitle => 'Extensions';
+
+ @override
+ String get extensionsDisabled => 'Disabled';
+
+ @override
+ String extensionsVersion(String version) {
+ return 'Version $version';
+ }
+
+ @override
+ String extensionsAuthor(String author) {
+ return 'by $author';
+ }
+
+ @override
+ String get extensionsUninstall => 'Uninstall';
+
+ @override
+ String get storeTitle => 'Extension Store';
+
+ @override
+ String get storeSearch => 'Search extensions...';
+
+ @override
+ String get storeInstall => 'Install';
+
+ @override
+ String get storeInstalled => 'Installed';
+
+ @override
+ String get storeUpdate => 'Update';
+
+ @override
+ String get aboutTitle => 'About';
+
+ @override
+ String get aboutContributors => 'Contributors';
+
+ @override
+ String get aboutMobileDeveloper => 'Mobile version developer';
+
+ @override
+ String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
+
+ @override
+ String get aboutLogoArtist =>
+ 'The talented artist who created our beautiful app logo!';
+
+ @override
+ String get aboutTranslators => 'Translators';
+
+ @override
+ String get aboutSpecialThanks => 'Special Thanks';
+
+ @override
+ String get aboutLinks => 'Links';
+
+ @override
+ String get aboutMobileSource => 'Mobile source code';
+
+ @override
+ String get aboutPCSource => 'PC source code';
+
+ @override
+ String get aboutReportIssue => 'Report an issue';
+
+ @override
+ String get aboutReportIssueSubtitle => 'Report any problems you encounter';
+
+ @override
+ String get aboutFeatureRequest => 'Feature request';
+
+ @override
+ String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
+
+ @override
+ String get aboutTelegramChannel => 'Telegram Channel';
+
+ @override
+ String get aboutTelegramChannelSubtitle => 'Announcements and updates';
+
+ @override
+ String get aboutTelegramChat => 'Telegram Community';
+
+ @override
+ String get aboutTelegramChatSubtitle => 'Chat with other users';
+
+ @override
+ String get aboutSocial => 'Social';
+
+ @override
+ String get aboutApp => 'App';
+
+ @override
+ String get aboutVersion => 'Version';
+
+ @override
+ String get aboutBinimumDesc =>
+ 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
+
+ @override
+ String get aboutSachinsenalDesc =>
+ 'The original HiFi project creator. The foundation of Tidal integration!';
+
+ @override
+ String get aboutSjdonadoDesc =>
+ 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
+
+ @override
+ String get aboutDabMusic => 'DAB Music';
+
+ @override
+ String get aboutDabMusicDesc =>
+ 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
+
+ @override
+ String get aboutSpotiSaver => 'SpotiSaver';
+
+ @override
+ String get aboutSpotiSaverDesc =>
+ 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
+
+ @override
+ String get aboutAppDescription =>
+ 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
+
+ @override
+ String get artistAlbums => 'Albums';
+
+ @override
+ String get artistSingles => 'Singles & EPs';
+
+ @override
+ String get artistCompilations => 'Compilations';
+
+ @override
+ String get artistPopular => 'Popular';
+
+ @override
+ String artistMonthlyListeners(String count) {
+ return '$count monthly listeners';
+ }
+
+ @override
+ String get trackMetadataService => 'Service';
+
+ @override
+ String get trackMetadataPlay => 'Play';
+
+ @override
+ String get trackMetadataShare => 'Share';
+
+ @override
+ String get trackMetadataDelete => 'Delete';
+
+ @override
+ String get setupGrantPermission => 'Grant Permission';
+
+ @override
+ String get setupSkip => 'Skip for now';
+
+ @override
+ String get setupStorageAccessRequired => 'Storage Access Required';
+
+ @override
+ String get setupStorageAccessMessageAndroid11 =>
+ 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.';
+
+ @override
+ String get setupOpenSettings => 'Open Settings';
+
+ @override
+ String get setupPermissionDeniedMessage =>
+ 'Permission denied. Please grant all permissions to continue.';
+
+ @override
+ String setupPermissionRequired(String permissionType) {
+ return '$permissionType Permission Required';
+ }
+
+ @override
+ String setupPermissionRequiredMessage(String permissionType) {
+ return '$permissionType permission is required for the best experience. You can change this later in Settings.';
+ }
+
+ @override
+ String get setupUseDefaultFolder => 'Use Default Folder?';
+
+ @override
+ String get setupNoFolderSelected =>
+ 'No folder selected. Would you like to use the default Music folder?';
+
+ @override
+ String get setupUseDefault => 'Use Default';
+
+ @override
+ String get setupDownloadLocationTitle => 'Download Location';
+
+ @override
+ String get setupDownloadLocationIosMessage =>
+ 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.';
+
+ @override
+ String get setupAppDocumentsFolder => 'App Documents Folder';
+
+ @override
+ String get setupAppDocumentsFolderSubtitle =>
+ 'Recommended - accessible via Files app';
+
+ @override
+ String get setupChooseFromFiles => 'Choose from Files';
+
+ @override
+ String get setupChooseFromFilesSubtitle => 'Select iCloud or other location';
+
+ @override
+ String get setupIosEmptyFolderWarning =>
+ 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
+
+ @override
+ String get setupIcloudNotSupported =>
+ 'iCloud Drive is not supported. Please use the app Documents folder.';
+
+ @override
+ String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
+
+ @override
+ String get setupStorageGranted => 'Storage Permission Granted!';
+
+ @override
+ String get setupStorageRequired => 'Storage Permission Required';
+
+ @override
+ String get setupStorageDescription =>
+ 'SpotiFLAC needs storage permission to save your downloaded music files.';
+
+ @override
+ String get setupNotificationGranted => 'Notification Permission Granted!';
+
+ @override
+ String get setupNotificationEnable => 'Enable Notifications';
+
+ @override
+ String get setupFolderChoose => 'Choose Download Folder';
+
+ @override
+ String get setupFolderDescription =>
+ 'Select a folder where your downloaded music will be saved.';
+
+ @override
+ String get setupSelectFolder => 'Select Folder';
+
+ @override
+ String get setupEnableNotifications => 'Enable Notifications';
+
+ @override
+ String get setupNotificationBackgroundDescription =>
+ 'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
+
+ @override
+ String get setupSkipForNow => 'Skip for now';
+
+ @override
+ String get setupNext => 'Next';
+
+ @override
+ String get setupGetStarted => 'Get Started';
+
+ @override
+ String get setupAllowAccessToManageFiles =>
+ 'Please enable \"Allow access to manage all files\" in the next screen.';
+
+ @override
+ String get dialogCancel => 'Cancel';
+
+ @override
+ String get dialogSave => 'Save';
+
+ @override
+ String get dialogDelete => 'Delete';
+
+ @override
+ String get dialogRetry => 'Retry';
+
+ @override
+ String get dialogClear => 'Clear';
+
+ @override
+ String get dialogDone => 'Done';
+
+ @override
+ String get dialogImport => 'Import';
+
@override
String get dialogDiscard => 'Discard';
@@ -1809,7 +4770,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get tutorialWelcomeTip2 =>
- 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
+ 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -2344,2117 +5305,6 @@ class AppLocalizationsZh extends AppLocalizations {
'Artist folders use Track Artist only';
}
-/// The translations for Chinese, as used in China (`zh_CN`).
-class AppLocalizationsZhCn extends AppLocalizationsZh {
- AppLocalizationsZhCn() : super('zh_CN');
-
- @override
- String get appName => 'SpotiFLAC';
-
- @override
- String get navHome => 'Home';
-
- @override
- String get navLibrary => 'Library';
-
- @override
- String get navSettings => 'Settings';
-
- @override
- String get navStore => 'Store';
-
- @override
- String get homeTitle => 'Home';
-
- @override
- String get homeSubtitle => 'Paste a Spotify link or search by name';
-
- @override
- String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
-
- @override
- String get homeRecent => 'Recent';
-
- @override
- String get historyFilterAll => 'All';
-
- @override
- String get historyFilterAlbums => 'Albums';
-
- @override
- String get historyFilterSingles => 'Singles';
-
- @override
- String get historySearchHint => 'Search history...';
-
- @override
- String get settingsTitle => 'Settings';
-
- @override
- String get settingsDownload => 'Download';
-
- @override
- String get settingsAppearance => 'Appearance';
-
- @override
- String get settingsOptions => 'Options';
-
- @override
- String get settingsExtensions => 'Extensions';
-
- @override
- String get settingsAbout => 'About';
-
- @override
- String get downloadTitle => 'Download';
-
- @override
- String get downloadAskQualitySubtitle =>
- 'Show quality picker for each download';
-
- @override
- String get downloadFilenameFormat => 'Filename Format';
-
- @override
- String get downloadFolderOrganization => 'Folder Organization';
-
- @override
- String get appearanceTitle => 'Appearance';
-
- @override
- String get appearanceThemeSystem => 'System';
-
- @override
- String get appearanceThemeLight => 'Light';
-
- @override
- String get appearanceThemeDark => 'Dark';
-
- @override
- String get appearanceDynamicColor => 'Dynamic Color';
-
- @override
- String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
-
- @override
- String get appearanceHistoryView => 'History View';
-
- @override
- String get appearanceHistoryViewList => 'List';
-
- @override
- String get appearanceHistoryViewGrid => 'Grid';
-
- @override
- String get optionsTitle => 'Options';
-
- @override
- String get optionsPrimaryProvider => 'Primary Provider';
-
- @override
- String get optionsPrimaryProviderSubtitle =>
- 'Service used when searching by track name.';
-
- @override
- String optionsUsingExtension(String extensionName) {
- return 'Using extension: $extensionName';
- }
-
- @override
- String get optionsSwitchBack =>
- 'Tap Deezer or Spotify to switch back from extension';
-
- @override
- String get optionsAutoFallback => 'Auto Fallback';
-
- @override
- String get optionsAutoFallbackSubtitle =>
- 'Try other services if download fails';
-
- @override
- String get optionsUseExtensionProviders => 'Use Extension Providers';
-
- @override
- String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
-
- @override
- String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
-
- @override
- String get optionsEmbedLyrics => 'Embed Lyrics';
-
- @override
- String get optionsEmbedLyricsSubtitle =>
- 'Embed synced lyrics into FLAC files';
-
- @override
- String get optionsMaxQualityCover => 'Max Quality Cover';
-
- @override
- String get optionsMaxQualityCoverSubtitle =>
- 'Download highest resolution cover art';
-
- @override
- String get optionsConcurrentDownloads => 'Concurrent Downloads';
-
- @override
- String get optionsConcurrentSequential => 'Sequential (1 at a time)';
-
- @override
- String optionsConcurrentParallel(int count) {
- return '$count parallel downloads';
- }
-
- @override
- String get optionsConcurrentWarning =>
- 'Parallel downloads may trigger rate limiting';
-
- @override
- String get optionsExtensionStore => 'Extension Store';
-
- @override
- String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
-
- @override
- String get optionsCheckUpdates => 'Check for Updates';
-
- @override
- String get optionsCheckUpdatesSubtitle =>
- 'Notify when new version is available';
-
- @override
- String get optionsUpdateChannel => 'Update Channel';
-
- @override
- String get optionsUpdateChannelStable => 'Stable releases only';
-
- @override
- String get optionsUpdateChannelPreview => 'Get preview releases';
-
- @override
- String get optionsUpdateChannelWarning =>
- 'Preview may contain bugs or incomplete features';
-
- @override
- String get optionsClearHistory => 'Clear Download History';
-
- @override
- String get optionsClearHistorySubtitle =>
- 'Remove all downloaded tracks from history';
-
- @override
- String get optionsDetailedLogging => 'Detailed Logging';
-
- @override
- String get optionsDetailedLoggingOn => 'Detailed logs are being recorded';
-
- @override
- String get optionsDetailedLoggingOff => 'Enable for bug reports';
-
- @override
- String get optionsSpotifyCredentials => 'Spotify Credentials';
-
- @override
- String optionsSpotifyCredentialsConfigured(String clientId) {
- return 'Client ID: $clientId...';
- }
-
- @override
- String get optionsSpotifyCredentialsRequired => 'Required - tap to configure';
-
- @override
- String get optionsSpotifyWarning =>
- 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
-
- @override
- String get optionsSpotifyDeprecationWarning =>
- 'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
-
- @override
- String get extensionsTitle => 'Extensions';
-
- @override
- String get extensionsDisabled => 'Disabled';
-
- @override
- String extensionsVersion(String version) {
- return 'Version $version';
- }
-
- @override
- String extensionsAuthor(String author) {
- return 'by $author';
- }
-
- @override
- String get extensionsUninstall => 'Uninstall';
-
- @override
- String get storeTitle => 'Extension Store';
-
- @override
- String get storeSearch => 'Search extensions...';
-
- @override
- String get storeInstall => 'Install';
-
- @override
- String get storeInstalled => 'Installed';
-
- @override
- String get storeUpdate => 'Update';
-
- @override
- String get aboutTitle => 'About';
-
- @override
- String get aboutContributors => 'Contributors';
-
- @override
- String get aboutMobileDeveloper => 'Mobile version developer';
-
- @override
- String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
-
- @override
- String get aboutLogoArtist =>
- 'The talented artist who created our beautiful app logo!';
-
- @override
- String get aboutTranslators => 'Translators';
-
- @override
- String get aboutSpecialThanks => 'Special Thanks';
-
- @override
- String get aboutLinks => 'Links';
-
- @override
- String get aboutMobileSource => 'Mobile source code';
-
- @override
- String get aboutPCSource => 'PC source code';
-
- @override
- String get aboutReportIssue => 'Report an issue';
-
- @override
- String get aboutReportIssueSubtitle => 'Report any problems you encounter';
-
- @override
- String get aboutFeatureRequest => 'Feature request';
-
- @override
- String get aboutFeatureRequestSubtitle => 'Suggest new features for the app';
-
- @override
- String get aboutTelegramChannel => 'Telegram Channel';
-
- @override
- String get aboutTelegramChannelSubtitle => 'Announcements and updates';
-
- @override
- String get aboutTelegramChat => 'Telegram Community';
-
- @override
- String get aboutTelegramChatSubtitle => 'Chat with other users';
-
- @override
- String get aboutSocial => 'Social';
-
- @override
- String get aboutApp => 'App';
-
- @override
- String get aboutVersion => 'Version';
-
- @override
- String get aboutBinimumDesc =>
- 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
-
- @override
- String get aboutSachinsenalDesc =>
- 'The original HiFi project creator. The foundation of Tidal integration!';
-
- @override
- String get aboutSjdonadoDesc =>
- 'Creator of I Don\'t Have Spotify (IDHS). The fallback link resolver that saves the day!';
-
- @override
- String get aboutDabMusic => 'DAB Music';
-
- @override
- String get aboutDabMusicDesc =>
- 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
-
- @override
- String get aboutSpotiSaver => 'SpotiSaver';
-
- @override
- String get aboutSpotiSaverDesc =>
- 'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
-
- @override
- String get aboutAppDescription =>
- 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
-
- @override
- String get artistAlbums => 'Albums';
-
- @override
- String get artistSingles => 'Singles & EPs';
-
- @override
- String get artistCompilations => 'Compilations';
-
- @override
- String get artistPopular => 'Popular';
-
- @override
- String artistMonthlyListeners(String count) {
- return '$count monthly listeners';
- }
-
- @override
- String get trackMetadataService => 'Service';
-
- @override
- String get trackMetadataPlay => 'Play';
-
- @override
- String get trackMetadataShare => 'Share';
-
- @override
- String get trackMetadataDelete => 'Delete';
-
- @override
- String get setupGrantPermission => 'Grant Permission';
-
- @override
- String get setupSkip => 'Skip for now';
-
- @override
- String get setupStorageAccessRequired => 'Storage Access Required';
-
- @override
- String get setupStorageAccessMessageAndroid11 =>
- 'Android 11+ requires \"All files access\" permission to save files to your chosen download folder.';
-
- @override
- String get setupOpenSettings => 'Open Settings';
-
- @override
- String get setupPermissionDeniedMessage =>
- 'Permission denied. Please grant all permissions to continue.';
-
- @override
- String setupPermissionRequired(String permissionType) {
- return '$permissionType Permission Required';
- }
-
- @override
- String setupPermissionRequiredMessage(String permissionType) {
- return '$permissionType permission is required for the best experience. You can change this later in Settings.';
- }
-
- @override
- String get setupUseDefaultFolder => 'Use Default Folder?';
-
- @override
- String get setupNoFolderSelected =>
- 'No folder selected. Would you like to use the default Music folder?';
-
- @override
- String get setupUseDefault => 'Use Default';
-
- @override
- String get setupDownloadLocationTitle => 'Download Location';
-
- @override
- String get setupDownloadLocationIosMessage =>
- 'On iOS, downloads are saved to the app\'s Documents folder. You can access them via the Files app.';
-
- @override
- String get setupAppDocumentsFolder => 'App Documents Folder';
-
- @override
- String get setupAppDocumentsFolderSubtitle =>
- 'Recommended - accessible via Files app';
-
- @override
- String get setupChooseFromFiles => 'Choose from Files';
-
- @override
- String get setupChooseFromFilesSubtitle => 'Select iCloud or other location';
-
- @override
- String get setupIosEmptyFolderWarning =>
- 'iOS limitation: Empty folders cannot be selected. Choose a folder with at least one file.';
-
- @override
- String get setupIcloudNotSupported =>
- 'iCloud Drive is not supported. Please use the app Documents folder.';
-
- @override
- String get setupDownloadInFlac => 'Download Spotify tracks in FLAC';
-
- @override
- String get setupStorageGranted => 'Storage Permission Granted!';
-
- @override
- String get setupStorageRequired => 'Storage Permission Required';
-
- @override
- String get setupStorageDescription =>
- 'SpotiFLAC needs storage permission to save your downloaded music files.';
-
- @override
- String get setupNotificationGranted => 'Notification Permission Granted!';
-
- @override
- String get setupNotificationEnable => 'Enable Notifications';
-
- @override
- String get setupFolderChoose => 'Choose Download Folder';
-
- @override
- String get setupFolderDescription =>
- 'Select a folder where your downloaded music will be saved.';
-
- @override
- String get setupSelectFolder => 'Select Folder';
-
- @override
- String get setupEnableNotifications => 'Enable Notifications';
-
- @override
- String get setupNotificationBackgroundDescription =>
- 'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
-
- @override
- String get setupSkipForNow => 'Skip for now';
-
- @override
- String get setupNext => 'Next';
-
- @override
- String get setupGetStarted => 'Get Started';
-
- @override
- String get setupAllowAccessToManageFiles =>
- 'Please enable \"Allow access to manage all files\" in the next screen.';
-
- @override
- String get dialogCancel => 'Cancel';
-
- @override
- String get dialogSave => 'Save';
-
- @override
- String get dialogDelete => 'Delete';
-
- @override
- String get dialogRetry => 'Retry';
-
- @override
- String get dialogClear => 'Clear';
-
- @override
- String get dialogDone => 'Done';
-
- @override
- String get dialogImport => 'Import';
-
- @override
- String get dialogDiscard => 'Discard';
-
- @override
- String get dialogRemove => 'Remove';
-
- @override
- String get dialogUninstall => 'Uninstall';
-
- @override
- String get dialogDiscardChanges => 'Discard Changes?';
-
- @override
- String get dialogUnsavedChanges =>
- 'You have unsaved changes. Do you want to discard them?';
-
- @override
- String get dialogClearAll => 'Clear All';
-
- @override
- String get dialogRemoveExtension => 'Remove Extension';
-
- @override
- String get dialogRemoveExtensionMessage =>
- 'Are you sure you want to remove this extension? This cannot be undone.';
-
- @override
- String get dialogUninstallExtension => 'Uninstall Extension?';
-
- @override
- String dialogUninstallExtensionMessage(String extensionName) {
- return 'Are you sure you want to remove $extensionName?';
- }
-
- @override
- String get dialogClearHistoryTitle => 'Clear History';
-
- @override
- String get dialogClearHistoryMessage =>
- 'Are you sure you want to clear all download history? This cannot be undone.';
-
- @override
- String get dialogDeleteSelectedTitle => 'Delete Selected';
-
- @override
- String dialogDeleteSelectedMessage(int count) {
- String _temp0 = intl.Intl.pluralLogic(
- count,
- locale: localeName,
- other: 'tracks',
- one: 'track',
- );
- return 'Delete $count $_temp0 from history?\n\nThis will also delete the files from storage.';
- }
-
- @override
- String get dialogImportPlaylistTitle => 'Import Playlist';
-
- @override
- String dialogImportPlaylistMessage(int count) {
- return 'Found $count tracks in CSV. Add them to download queue?';
- }
-
- @override
- String csvImportTracks(int count) {
- return '$count tracks from CSV';
- }
-
- @override
- String snackbarAddedToQueue(String trackName) {
- return 'Added \"$trackName\" to queue';
- }
-
- @override
- String snackbarAddedTracksToQueue(int count) {
- return 'Added $count tracks to queue';
- }
-
- @override
- String snackbarAlreadyDownloaded(String trackName) {
- return '\"$trackName\" already downloaded';
- }
-
- @override
- String snackbarAlreadyInLibrary(String trackName) {
- return '\"$trackName\" already exists in your library';
- }
-
- @override
- String get snackbarHistoryCleared => 'History cleared';
-
- @override
- String get snackbarCredentialsSaved => 'Credentials saved';
-
- @override
- String get snackbarCredentialsCleared => 'Credentials cleared';
-
- @override
- String snackbarDeletedTracks(int count) {
- String _temp0 = intl.Intl.pluralLogic(
- count,
- locale: localeName,
- other: 'tracks',
- one: 'track',
- );
- return 'Deleted $count $_temp0';
- }
-
- @override
- String snackbarCannotOpenFile(String error) {
- return 'Cannot open file: $error';
- }
-
- @override
- String get snackbarFillAllFields => 'Please fill all fields';
-
- @override
- String get snackbarViewQueue => 'View Queue';
-
- @override
- String snackbarUrlCopied(String platform) {
- return '$platform URL copied to clipboard';
- }
-
- @override
- String get snackbarFileNotFound => 'File not found';
-
- @override
- String get snackbarSelectExtFile => 'Please select a .spotiflac-ext file';
-
- @override
- String get snackbarProviderPrioritySaved => 'Provider priority saved';
-
- @override
- String get snackbarMetadataProviderSaved =>
- 'Metadata provider priority saved';
-
- @override
- String snackbarExtensionInstalled(String extensionName) {
- return '$extensionName installed.';
- }
-
- @override
- String snackbarExtensionUpdated(String extensionName) {
- return '$extensionName updated.';
- }
-
- @override
- String get snackbarFailedToInstall => 'Failed to install extension';
-
- @override
- String get snackbarFailedToUpdate => 'Failed to update extension';
-
- @override
- String get errorRateLimited => 'Rate Limited';
-
- @override
- String get errorRateLimitedMessage =>
- 'Too many requests. Please wait a moment before searching again.';
-
- @override
- String get errorNoTracksFound => 'No tracks found';
-
- @override
- String errorMissingExtensionSource(String item) {
- return 'Cannot load $item: missing extension source';
- }
-
- @override
- String get actionPause => 'Pause';
-
- @override
- String get actionResume => 'Resume';
-
- @override
- String get actionCancel => 'Cancel';
-
- @override
- String get actionSelectAll => 'Select All';
-
- @override
- String get actionDeselect => 'Deselect';
-
- @override
- String get actionRemoveCredentials => 'Remove Credentials';
-
- @override
- String get actionSaveCredentials => 'Save Credentials';
-
- @override
- String selectionSelected(int count) {
- return '$count selected';
- }
-
- @override
- String get selectionAllSelected => 'All tracks selected';
-
- @override
- String get selectionSelectToDelete => 'Select tracks to delete';
-
- @override
- String progressFetchingMetadata(int current, int total) {
- return 'Fetching metadata... $current/$total';
- }
-
- @override
- String get progressReadingCsv => 'Reading CSV...';
-
- @override
- String get searchSongs => 'Songs';
-
- @override
- String get searchArtists => 'Artists';
-
- @override
- String get searchAlbums => 'Albums';
-
- @override
- String get searchPlaylists => 'Playlists';
-
- @override
- String get tooltipPlay => 'Play';
-
- @override
- String get filenameFormat => 'Filename Format';
-
- @override
- String get folderOrganizationNone => 'No organization';
-
- @override
- String get folderOrganizationByArtist => 'By Artist';
-
- @override
- String get folderOrganizationByAlbum => 'By Album';
-
- @override
- String get folderOrganizationByArtistAlbum => 'Artist/Album';
-
- @override
- String get folderOrganizationDescription =>
- 'Organize downloaded files into folders';
-
- @override
- String get folderOrganizationNoneSubtitle => 'All files in download folder';
-
- @override
- String get folderOrganizationByArtistSubtitle =>
- 'Separate folder for each artist';
-
- @override
- String get folderOrganizationByAlbumSubtitle =>
- 'Separate folder for each album';
-
- @override
- String get folderOrganizationByArtistAlbumSubtitle =>
- 'Nested folders for artist and album';
-
- @override
- String get updateAvailable => 'Update Available';
-
- @override
- String get updateLater => 'Later';
-
- @override
- String get updateStartingDownload => 'Starting download...';
-
- @override
- String get updateDownloadFailed => 'Download failed';
-
- @override
- String get updateFailedMessage => 'Failed to download update';
-
- @override
- String get updateNewVersionReady => 'A new version is ready';
-
- @override
- String get updateCurrent => 'Current';
-
- @override
- String get updateNew => 'New';
-
- @override
- String get updateDownloading => 'Downloading...';
-
- @override
- String get updateWhatsNew => 'What\'s New';
-
- @override
- String get updateDownloadInstall => 'Download & Install';
-
- @override
- String get updateDontRemind => 'Don\'t remind';
-
- @override
- String get providerPriorityTitle => 'Provider Priority';
-
- @override
- String get providerPriorityDescription =>
- 'Drag to reorder download providers. The app will try providers from top to bottom when downloading tracks.';
-
- @override
- String get providerPriorityInfo =>
- 'If a track is not available on the first provider, the app will automatically try the next one.';
-
- @override
- String get providerBuiltIn => 'Built-in';
-
- @override
- String get providerExtension => 'Extension';
-
- @override
- String get metadataProviderPriorityTitle => 'Metadata Priority';
-
- @override
- String get metadataProviderPriorityDescription =>
- 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.';
-
- @override
- String get metadataProviderPriorityInfo =>
- 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.';
-
- @override
- String get metadataNoRateLimits => 'No rate limits';
-
- @override
- String get metadataMayRateLimit => 'May rate limit';
-
- @override
- String get logTitle => 'Logs';
-
- @override
- String get logCopied => 'Logs copied to clipboard';
-
- @override
- String get logSearchHint => 'Search logs...';
-
- @override
- String get logFilterLevel => 'Level';
-
- @override
- String get logFilterSection => 'Filter';
-
- @override
- String get logShareLogs => 'Share logs';
-
- @override
- String get logClearLogs => 'Clear logs';
-
- @override
- String get logClearLogsTitle => 'Clear Logs';
-
- @override
- String get logClearLogsMessage => 'Are you sure you want to clear all logs?';
-
- @override
- String get logFilterBySeverity => 'Filter logs by severity';
-
- @override
- String get logNoLogsYet => 'No logs yet';
-
- @override
- String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app';
-
- @override
- String logEntriesFiltered(int count) {
- return 'Entries ($count filtered)';
- }
-
- @override
- String logEntries(int count) {
- return 'Entries ($count)';
- }
-
- @override
- String get credentialsTitle => 'Spotify Credentials';
-
- @override
- String get credentialsDescription =>
- 'Enter your Client ID and Secret to use your own Spotify application quota.';
-
- @override
- String get credentialsClientId => 'Client ID';
-
- @override
- String get credentialsClientIdHint => 'Paste Client ID';
-
- @override
- String get credentialsClientSecret => 'Client Secret';
-
- @override
- String get credentialsClientSecretHint => 'Paste Client Secret';
-
- @override
- String get channelStable => 'Stable';
-
- @override
- String get channelPreview => 'Preview';
-
- @override
- String get sectionSearchSource => 'Search Source';
-
- @override
- String get sectionDownload => 'Download';
-
- @override
- String get sectionPerformance => 'Performance';
-
- @override
- String get sectionApp => 'App';
-
- @override
- String get sectionData => 'Data';
-
- @override
- String get sectionDebug => 'Debug';
-
- @override
- String get sectionService => 'Service';
-
- @override
- String get sectionAudioQuality => 'Audio Quality';
-
- @override
- String get sectionFileSettings => 'File Settings';
-
- @override
- String get sectionLyrics => 'Lyrics';
-
- @override
- String get lyricsMode => 'Lyrics Mode';
-
- @override
- String get lyricsModeDescription =>
- 'Choose how lyrics are saved with your downloads';
-
- @override
- String get lyricsModeEmbed => 'Embed in file';
-
- @override
- String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
-
- @override
- String get lyricsModeExternal => 'External .lrc file';
-
- @override
- String get lyricsModeExternalSubtitle =>
- 'Separate .lrc file for players like Samsung Music';
-
- @override
- String get lyricsModeBoth => 'Both';
-
- @override
- String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
-
- @override
- String get sectionColor => 'Color';
-
- @override
- String get sectionTheme => 'Theme';
-
- @override
- String get sectionLayout => 'Layout';
-
- @override
- String get sectionLanguage => 'Language';
-
- @override
- String get appearanceLanguage => 'App Language';
-
- @override
- String get settingsAppearanceSubtitle => 'Theme, colors, display';
-
- @override
- String get settingsDownloadSubtitle => 'Service, quality, filename format';
-
- @override
- String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
-
- @override
- String get settingsExtensionsSubtitle => 'Manage download providers';
-
- @override
- String get settingsLogsSubtitle => 'View app logs for debugging';
-
- @override
- String get loadingSharedLink => 'Loading shared link...';
-
- @override
- String get pressBackAgainToExit => 'Press back again to exit';
-
- @override
- String downloadAllCount(int count) {
- return 'Download All ($count)';
- }
-
- @override
- String tracksCount(int count) {
- String _temp0 = intl.Intl.pluralLogic(
- count,
- locale: localeName,
- other: '$count tracks',
- one: '1 track',
- );
- return '$_temp0';
- }
-
- @override
- String get trackCopyFilePath => 'Copy file path';
-
- @override
- String get trackRemoveFromDevice => 'Remove from device';
-
- @override
- String get trackLoadLyrics => 'Load Lyrics';
-
- @override
- String get trackMetadata => 'Metadata';
-
- @override
- String get trackFileInfo => 'File Info';
-
- @override
- String get trackLyrics => 'Lyrics';
-
- @override
- String get trackFileNotFound => 'File not found';
-
- @override
- String get trackOpenInDeezer => 'Open in Deezer';
-
- @override
- String get trackOpenInSpotify => 'Open in Spotify';
-
- @override
- String get trackTrackName => 'Track name';
-
- @override
- String get trackArtist => 'Artist';
-
- @override
- String get trackAlbumArtist => 'Album artist';
-
- @override
- String get trackAlbum => 'Album';
-
- @override
- String get trackTrackNumber => 'Track number';
-
- @override
- String get trackDiscNumber => 'Disc number';
-
- @override
- String get trackDuration => 'Duration';
-
- @override
- String get trackAudioQuality => 'Audio quality';
-
- @override
- String get trackReleaseDate => 'Release date';
-
- @override
- String get trackGenre => 'Genre';
-
- @override
- String get trackLabel => 'Label';
-
- @override
- String get trackCopyright => 'Copyright';
-
- @override
- String get trackDownloaded => 'Downloaded';
-
- @override
- String get trackCopyLyrics => 'Copy lyrics';
-
- @override
- String get trackLyricsNotAvailable => 'Lyrics not available for this track';
-
- @override
- String get trackLyricsTimeout => 'Request timed out. Try again later.';
-
- @override
- String get trackLyricsLoadFailed => 'Failed to load lyrics';
-
- @override
- String get trackEmbedLyrics => 'Embed Lyrics';
-
- @override
- String get trackLyricsEmbedded => 'Lyrics embedded successfully';
-
- @override
- String get trackInstrumental => 'Instrumental track';
-
- @override
- String get trackCopiedToClipboard => 'Copied to clipboard';
-
- @override
- String get trackDeleteConfirmTitle => 'Remove from device?';
-
- @override
- String get trackDeleteConfirmMessage =>
- 'This will permanently delete the downloaded file and remove it from your history.';
-
- @override
- String get dateToday => 'Today';
-
- @override
- String get dateYesterday => 'Yesterday';
-
- @override
- String dateDaysAgo(int count) {
- return '$count days ago';
- }
-
- @override
- String dateWeeksAgo(int count) {
- return '$count weeks ago';
- }
-
- @override
- String dateMonthsAgo(int count) {
- return '$count months ago';
- }
-
- @override
- String get storeFilterAll => 'All';
-
- @override
- String get storeFilterMetadata => 'Metadata';
-
- @override
- String get storeFilterDownload => 'Download';
-
- @override
- String get storeFilterUtility => 'Utility';
-
- @override
- String get storeFilterLyrics => 'Lyrics';
-
- @override
- String get storeFilterIntegration => 'Integration';
-
- @override
- String get storeClearFilters => 'Clear filters';
-
- @override
- String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
-
- @override
- String get extensionDefaultProviderSubtitle => 'Use built-in search';
-
- @override
- String get extensionAuthor => 'Author';
-
- @override
- String get extensionId => 'ID';
-
- @override
- String get extensionError => 'Error';
-
- @override
- String get extensionCapabilities => 'Capabilities';
-
- @override
- String get extensionMetadataProvider => 'Metadata Provider';
-
- @override
- String get extensionDownloadProvider => 'Download Provider';
-
- @override
- String get extensionLyricsProvider => 'Lyrics Provider';
-
- @override
- String get extensionUrlHandler => 'URL Handler';
-
- @override
- String get extensionQualityOptions => 'Quality Options';
-
- @override
- String get extensionPostProcessingHooks => 'Post-Processing Hooks';
-
- @override
- String get extensionPermissions => 'Permissions';
-
- @override
- String get extensionSettings => 'Settings';
-
- @override
- String get extensionRemoveButton => 'Remove Extension';
-
- @override
- String get extensionUpdated => 'Updated';
-
- @override
- String get extensionMinAppVersion => 'Min App Version';
-
- @override
- String get extensionCustomTrackMatching => 'Custom Track Matching';
-
- @override
- String get extensionPostProcessing => 'Post-Processing';
-
- @override
- String extensionHooksAvailable(int count) {
- return '$count hook(s) available';
- }
-
- @override
- String extensionPatternsCount(int count) {
- return '$count pattern(s)';
- }
-
- @override
- String extensionStrategy(String strategy) {
- return 'Strategy: $strategy';
- }
-
- @override
- String get extensionsProviderPrioritySection => 'Provider Priority';
-
- @override
- String get extensionsInstalledSection => 'Installed Extensions';
-
- @override
- String get extensionsNoExtensions => 'No extensions installed';
-
- @override
- String get extensionsNoExtensionsSubtitle =>
- 'Install .spotiflac-ext files to add new providers';
-
- @override
- String get extensionsInstallButton => 'Install Extension';
-
- @override
- String get extensionsInfoTip =>
- 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.';
-
- @override
- String get extensionsInstalledSuccess => 'Extension installed successfully';
-
- @override
- String get extensionsDownloadPriority => 'Download Priority';
-
- @override
- String get extensionsDownloadPrioritySubtitle => 'Set download service order';
-
- @override
- String get extensionsNoDownloadProvider =>
- 'No extensions with download provider';
-
- @override
- String get extensionsMetadataPriority => 'Metadata Priority';
-
- @override
- String get extensionsMetadataPrioritySubtitle =>
- 'Set search & metadata source order';
-
- @override
- String get extensionsNoMetadataProvider =>
- 'No extensions with metadata provider';
-
- @override
- String get extensionsSearchProvider => 'Search Provider';
-
- @override
- String get extensionsNoCustomSearch => 'No extensions with custom search';
-
- @override
- String get extensionsSearchProviderDescription =>
- 'Choose which service to use for searching tracks';
-
- @override
- String get extensionsCustomSearch => 'Custom search';
-
- @override
- String get extensionsErrorLoading => 'Error loading extension';
-
- @override
- String get qualityFlacLossless => 'FLAC Lossless';
-
- @override
- String get qualityFlacLosslessSubtitle => '16-bit / 44.1kHz';
-
- @override
- String get qualityHiResFlac => 'Hi-Res FLAC';
-
- @override
- String get qualityHiResFlacSubtitle => '24-bit / up to 96kHz';
-
- @override
- String get qualityHiResFlacMax => 'Hi-Res FLAC Max';
-
- @override
- String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
-
- @override
- 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 downloadAskBeforeDownload => 'Ask Before Download';
-
- @override
- String get downloadDirectory => 'Download Directory';
-
- @override
- String get downloadSeparateSinglesFolder => 'Separate Singles Folder';
-
- @override
- String get downloadAlbumFolderStructure => 'Album Folder Structure';
-
- @override
- String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
-
- @override
- String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
-
- @override
- String get downloadUsePrimaryArtistOnlyEnabled =>
- 'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
-
- @override
- String get downloadUsePrimaryArtistOnlyDisabled =>
- 'Full artist string used for folder name';
-
- @override
- String get downloadSelectQuality => 'Select Quality';
-
- @override
- String get downloadFrom => 'Download From';
-
- @override
- String get appearanceAmoledDark => 'AMOLED Dark';
-
- @override
- String get appearanceAmoledDarkSubtitle => 'Pure black background';
-
- @override
- String get queueClearAll => 'Clear All';
-
- @override
- String get queueClearAllMessage =>
- 'Are you sure you want to clear all downloads?';
-
- @override
- String get settingsAutoExportFailed => 'Auto-export failed downloads';
-
- @override
- String get settingsAutoExportFailedSubtitle =>
- 'Save failed downloads to TXT file automatically';
-
- @override
- String get settingsDownloadNetwork => 'Download Network';
-
- @override
- String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
-
- @override
- String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
-
- @override
- String get settingsDownloadNetworkSubtitle =>
- 'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
-
- @override
- String get albumFolderArtistAlbum => 'Artist / Album';
-
- @override
- String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
-
- @override
- String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
-
- @override
- String get albumFolderArtistYearAlbumSubtitle =>
- 'Albums/Artist Name/[2005] Album Name/';
-
- @override
- String get albumFolderAlbumOnly => 'Album Only';
-
- @override
- String get albumFolderAlbumOnlySubtitle => 'Albums/Album Name/';
-
- @override
- String get albumFolderYearAlbum => '[Year] Album';
-
- @override
- String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
-
- @override
- String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
-
- @override
- String get albumFolderArtistAlbumSinglesSubtitle =>
- 'Artist/Album/ and Artist/Singles/';
-
- @override
- String get downloadedAlbumDeleteSelected => 'Delete Selected';
-
- @override
- String downloadedAlbumDeleteMessage(int count) {
- String _temp0 = intl.Intl.pluralLogic(
- count,
- locale: localeName,
- other: 'tracks',
- one: 'track',
- );
- return 'Delete $count $_temp0 from this album?\n\nThis will also delete the files from storage.';
- }
-
- @override
- String downloadedAlbumSelectedCount(int count) {
- return '$count selected';
- }
-
- @override
- String get downloadedAlbumAllSelected => 'All tracks selected';
-
- @override
- String get downloadedAlbumTapToSelect => 'Tap tracks to select';
-
- @override
- String downloadedAlbumDeleteCount(int count) {
- String _temp0 = intl.Intl.pluralLogic(
- count,
- locale: localeName,
- other: 'tracks',
- one: 'track',
- );
- return 'Delete $count $_temp0';
- }
-
- @override
- String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
-
- @override
- String downloadedAlbumDiscHeader(int discNumber) {
- return 'Disc $discNumber';
- }
-
- @override
- String get recentTypeArtist => 'Artist';
-
- @override
- String get recentTypeAlbum => 'Album';
-
- @override
- String get recentTypeSong => 'Song';
-
- @override
- String get recentTypePlaylist => 'Playlist';
-
- @override
- String get recentEmpty => 'No recent items yet';
-
- @override
- String get recentShowAllDownloads => 'Show All Downloads';
-
- @override
- String recentPlaylistInfo(String name) {
- return 'Playlist: $name';
- }
-
- @override
- String get discographyDownload => 'Download Discography';
-
- @override
- String get discographyDownloadAll => 'Download All';
-
- @override
- String discographyDownloadAllSubtitle(int count, int albumCount) {
- return '$count tracks from $albumCount releases';
- }
-
- @override
- String get discographyAlbumsOnly => 'Albums Only';
-
- @override
- String discographyAlbumsOnlySubtitle(int count, int albumCount) {
- return '$count tracks from $albumCount albums';
- }
-
- @override
- String get discographySinglesOnly => 'Singles & EPs Only';
-
- @override
- String discographySinglesOnlySubtitle(int count, int albumCount) {
- return '$count tracks from $albumCount singles';
- }
-
- @override
- String get discographySelectAlbums => 'Select Albums...';
-
- @override
- String get discographySelectAlbumsSubtitle =>
- 'Choose specific albums or singles';
-
- @override
- String get discographyFetchingTracks => 'Fetching tracks...';
-
- @override
- String discographyFetchingAlbum(int current, int total) {
- return 'Fetching $current of $total...';
- }
-
- @override
- String discographySelectedCount(int count) {
- return '$count selected';
- }
-
- @override
- String get discographyDownloadSelected => 'Download Selected';
-
- @override
- String discographyAddedToQueue(int count) {
- return 'Added $count tracks to queue';
- }
-
- @override
- String discographySkippedDownloaded(int added, int skipped) {
- return '$added added, $skipped already downloaded';
- }
-
- @override
- String get discographyNoAlbums => 'No albums available';
-
- @override
- String get discographyFailedToFetch => 'Failed to fetch some albums';
-
- @override
- String get sectionStorageAccess => 'Storage Access';
-
- @override
- String get allFilesAccess => 'All Files Access';
-
- @override
- String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
-
- @override
- String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
-
- @override
- String get allFilesAccessDescription =>
- 'Enable this if you encounter write errors when saving to custom folders. Android 13+ restricts access to certain directories by default.';
-
- @override
- String get allFilesAccessDeniedMessage =>
- 'Permission was denied. Please enable \'All files access\' manually in system settings.';
-
- @override
- String get allFilesAccessDisabledMessage =>
- 'All Files Access disabled. The app will use limited storage access.';
-
- @override
- String get settingsLocalLibrary => 'Local Library';
-
- @override
- String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
-
- @override
- String get settingsCache => 'Storage & Cache';
-
- @override
- String get settingsCacheSubtitle => 'View size and clear cached data';
-
- @override
- String get libraryTitle => 'Local Library';
-
- @override
- String get libraryScanSettings => 'Scan Settings';
-
- @override
- String get libraryEnableLocalLibrary => 'Enable Local Library';
-
- @override
- String get libraryEnableLocalLibrarySubtitle =>
- 'Scan and track your existing music';
-
- @override
- String get libraryFolder => 'Library Folder';
-
- @override
- String get libraryFolderHint => 'Tap to select folder';
-
- @override
- String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
-
- @override
- String get libraryShowDuplicateIndicatorSubtitle =>
- 'Show when searching for existing tracks';
-
- @override
- String get libraryActions => 'Actions';
-
- @override
- String get libraryScan => 'Scan Library';
-
- @override
- String get libraryScanSubtitle => 'Scan for audio files';
-
- @override
- String get libraryScanSelectFolderFirst => 'Select a folder first';
-
- @override
- String get libraryCleanupMissingFiles => 'Cleanup Missing Files';
-
- @override
- String get libraryCleanupMissingFilesSubtitle =>
- 'Remove entries for files that no longer exist';
-
- @override
- String get libraryClear => 'Clear Library';
-
- @override
- String get libraryClearSubtitle => 'Remove all scanned tracks';
-
- @override
- String get libraryClearConfirmTitle => 'Clear Library';
-
- @override
- String get libraryClearConfirmMessage =>
- 'This will remove all scanned tracks from your library. Your actual music files will not be deleted.';
-
- @override
- String get libraryAbout => 'About Local Library';
-
- @override
- String get libraryAboutDescription =>
- 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
-
- @override
- String libraryLastScanned(String time) {
- return 'Last scanned: $time';
- }
-
- @override
- String get libraryLastScannedNever => 'Never';
-
- @override
- String get libraryScanning => 'Scanning...';
-
- @override
- String libraryScanProgress(String progress, int total) {
- return '$progress% of $total files';
- }
-
- @override
- String get libraryInLibrary => 'In Library';
-
- @override
- String libraryRemovedMissingFiles(int count) {
- return 'Removed $count missing files from library';
- }
-
- @override
- String get libraryCleared => 'Library cleared';
-
- @override
- String get libraryStorageAccessRequired => 'Storage Access Required';
-
- @override
- String get libraryStorageAccessMessage =>
- 'SpotiFLAC needs storage access to scan your music library. Please grant permission in settings.';
-
- @override
- String get libraryFolderNotExist => 'Selected folder does not exist';
-
- @override
- String get librarySourceDownloaded => 'Downloaded';
-
- @override
- String get librarySourceLocal => 'Local';
-
- @override
- String get libraryFilterAll => 'All';
-
- @override
- String get libraryFilterDownloaded => 'Downloaded';
-
- @override
- String get libraryFilterLocal => 'Local';
-
- @override
- String get libraryFilterTitle => 'Filters';
-
- @override
- String get libraryFilterReset => 'Reset';
-
- @override
- String get libraryFilterApply => 'Apply';
-
- @override
- String get libraryFilterSource => 'Source';
-
- @override
- String get libraryFilterQuality => 'Quality';
-
- @override
- String get libraryFilterQualityHiRes => 'Hi-Res (24bit)';
-
- @override
- String get libraryFilterQualityCD => 'CD (16bit)';
-
- @override
- String get libraryFilterQualityLossy => 'Lossy';
-
- @override
- String get libraryFilterFormat => 'Format';
-
- @override
- String get libraryFilterSort => 'Sort';
-
- @override
- String get libraryFilterSortLatest => 'Latest';
-
- @override
- String get libraryFilterSortOldest => 'Oldest';
-
- @override
- String get timeJustNow => 'Just now';
-
- @override
- String timeMinutesAgo(int count) {
- String _temp0 = intl.Intl.pluralLogic(
- count,
- locale: localeName,
- other: '$count minutes ago',
- one: '1 minute ago',
- );
- return '$_temp0';
- }
-
- @override
- String timeHoursAgo(int count) {
- String _temp0 = intl.Intl.pluralLogic(
- count,
- locale: localeName,
- other: '$count hours ago',
- one: '1 hour ago',
- );
- return '$_temp0';
- }
-
- @override
- String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
-
- @override
- String get tutorialWelcomeDesc =>
- 'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
-
- @override
- String get tutorialWelcomeTip1 =>
- 'Download music from Spotify, Deezer, or paste any supported URL';
-
- @override
- String get tutorialWelcomeTip2 => '从 Tidal、Qobuz 或 Deezer 获取 FLAC 品质音频';
-
- @override
- String get tutorialWelcomeTip3 =>
- 'Automatic metadata, cover art, and lyrics embedding';
-
- @override
- String get tutorialSearchTitle => 'Finding Music';
-
- @override
- String get tutorialSearchDesc =>
- 'There are two easy ways to find music you want to download.';
-
- @override
- String get tutorialDownloadTitle => 'Downloading Music';
-
- @override
- String get tutorialDownloadDesc =>
- 'Downloading music is simple and fast. Here\'s how it works.';
-
- @override
- String get tutorialLibraryTitle => 'Your Library';
-
- @override
- String get tutorialLibraryDesc =>
- 'All your downloaded music is organized in the Library tab.';
-
- @override
- String get tutorialLibraryTip1 =>
- 'View download progress and queue in the Library tab';
-
- @override
- String get tutorialLibraryTip2 =>
- 'Tap any track to play it with your music player';
-
- @override
- String get tutorialLibraryTip3 =>
- 'Switch between list and grid view for better browsing';
-
- @override
- String get tutorialExtensionsTitle => 'Extensions';
-
- @override
- String get tutorialExtensionsDesc =>
- 'Extend the app\'s capabilities with community extensions.';
-
- @override
- String get tutorialExtensionsTip1 =>
- 'Browse the Store tab to discover useful extensions';
-
- @override
- String get tutorialExtensionsTip2 =>
- 'Add new download providers or search sources';
-
- @override
- String get tutorialExtensionsTip3 =>
- 'Get lyrics, enhanced metadata, and more features';
-
- @override
- String get tutorialSettingsTitle => 'Customize Your Experience';
-
- @override
- String get tutorialSettingsDesc =>
- 'Personalize the app in Settings to match your preferences.';
-
- @override
- String get tutorialSettingsTip1 =>
- 'Change download location and folder organization';
-
- @override
- String get tutorialSettingsTip2 =>
- 'Set default audio quality and format preferences';
-
- @override
- String get tutorialSettingsTip3 => 'Customize app theme and appearance';
-
- @override
- String get tutorialReadyMessage =>
- 'You\'re all set! Start downloading your favorite music now.';
-
- @override
- String get libraryForceFullScan => 'Force Full Scan';
-
- @override
- String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
-
- @override
- String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
-
- @override
- String get cleanupOrphanedDownloadsSubtitle =>
- 'Remove history entries for files that no longer exist';
-
- @override
- String cleanupOrphanedDownloadsResult(int count) {
- return 'Removed $count orphaned entries from history';
- }
-
- @override
- String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
-
- @override
- String get cacheTitle => 'Storage & Cache';
-
- @override
- String get cacheSummaryTitle => 'Cache overview';
-
- @override
- String get cacheSummarySubtitle =>
- 'Clearing cache will not remove downloaded music files.';
-
- @override
- String cacheEstimatedTotal(String size) {
- return 'Estimated cache usage: $size';
- }
-
- @override
- String get cacheSectionStorage => 'Cached Data';
-
- @override
- String get cacheSectionMaintenance => 'Maintenance';
-
- @override
- String get cacheAppDirectory => 'App cache directory';
-
- @override
- String get cacheAppDirectoryDesc =>
- 'HTTP responses, WebView data, and other temporary app data.';
-
- @override
- String get cacheTempDirectory => 'Temporary directory';
-
- @override
- String get cacheTempDirectoryDesc =>
- 'Temporary files from downloads and audio conversion.';
-
- @override
- String get cacheCoverImage => 'Cover image cache';
-
- @override
- String get cacheCoverImageDesc =>
- 'Downloaded album and track cover art. Will re-download when viewed.';
-
- @override
- String get cacheLibraryCover => 'Library cover cache';
-
- @override
- String get cacheLibraryCoverDesc =>
- 'Cover art extracted from local music files. Will re-extract on next scan.';
-
- @override
- String get cacheExploreFeed => 'Explore feed cache';
-
- @override
- String get cacheExploreFeedDesc =>
- 'Explore tab content (new releases, trending). Will refresh on next visit.';
-
- @override
- String get cacheTrackLookup => 'Track lookup cache';
-
- @override
- String get cacheTrackLookupDesc =>
- 'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
-
- @override
- String get cacheCleanupUnusedDesc =>
- 'Remove orphaned download history and library entries for missing files.';
-
- @override
- String get cacheNoData => 'No cached data';
-
- @override
- String cacheSizeWithFiles(String size, int count) {
- return '$size in $count files';
- }
-
- @override
- String cacheSizeOnly(String size) {
- return '$size';
- }
-
- @override
- String cacheEntries(int count) {
- return '$count entries';
- }
-
- @override
- String cacheClearSuccess(String target) {
- return 'Cleared: $target';
- }
-
- @override
- String get cacheClearConfirmTitle => 'Clear cache?';
-
- @override
- String cacheClearConfirmMessage(String target) {
- return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
- }
-
- @override
- String get cacheClearAllConfirmTitle => 'Clear all cache?';
-
- @override
- String get cacheClearAllConfirmMessage =>
- 'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
-
- @override
- String get cacheClearAll => 'Clear all cache';
-
- @override
- String get cacheCleanupUnused => 'Cleanup unused data';
-
- @override
- String get cacheCleanupUnusedSubtitle =>
- 'Remove orphaned download history and missing library entries';
-
- @override
- String cacheCleanupResult(int downloadCount, int libraryCount) {
- return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
- }
-
- @override
- String get cacheRefreshStats => 'Refresh stats';
-
- @override
- String get trackSaveCoverArt => 'Save Cover Art';
-
- @override
- String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
-
- @override
- String get trackSaveLyrics => 'Save Lyrics (.lrc)';
-
- @override
- String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
-
- @override
- String get trackSaveLyricsProgress => 'Saving lyrics...';
-
- @override
- String get trackReEnrich => 'Re-enrich';
-
- @override
- String get trackReEnrichOnlineSubtitle =>
- 'Search metadata online and embed into file';
-
- @override
- String get trackEditMetadata => 'Edit Metadata';
-
- @override
- String trackCoverSaved(String fileName) {
- return 'Cover art saved to $fileName';
- }
-
- @override
- String get trackCoverNoSource => 'No cover art source available';
-
- @override
- String trackLyricsSaved(String fileName) {
- return 'Lyrics saved to $fileName';
- }
-
- @override
- String get trackReEnrichProgress => 'Re-enriching metadata...';
-
- @override
- String get trackReEnrichSearching => 'Searching metadata online...';
-
- @override
- String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
-
- @override
- String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
-
- @override
- String trackSaveFailed(String error) {
- return 'Failed: $error';
- }
-
- @override
- String get trackConvertFormat => 'Convert Format';
-
- @override
- String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
-
- @override
- String get trackConvertTitle => 'Convert Audio';
-
- @override
- String get trackConvertTargetFormat => 'Target Format';
-
- @override
- String get trackConvertBitrate => 'Bitrate';
-
- @override
- String get trackConvertConfirmTitle => 'Confirm Conversion';
-
- @override
- String trackConvertConfirmMessage(
- String sourceFormat,
- String targetFormat,
- String bitrate,
- ) {
- return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
- }
-
- @override
- String get trackConvertConverting => 'Converting audio...';
-
- @override
- String trackConvertSuccess(String format) {
- return 'Converted to $format successfully';
- }
-
- @override
- String get trackConvertFailed => 'Conversion failed';
-
- @override
- String downloadedAlbumDownloadedCount(int count) {
- return '$count downloaded';
- }
-
- @override
- String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
- 'Artist folders use Album Artist when available';
-
- @override
- String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
- 'Artist folders use Track Artist only';
-}
-
/// The translations for Chinese, as used in Taiwan (`zh_TW`).
class AppLocalizationsZhTw extends AppLocalizationsZh {
AppLocalizationsZhTw() : super('zh_TW');
@@ -4807,7 +5657,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get aboutAppDescription =>
- 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
+ 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@override
String get artistAlbums => 'Albums';
@@ -5202,6 +6052,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get filenameFormat => 'Filename Format';
+ @override
+ String get filenameShowAdvancedTags => 'Show advanced tags';
+
+ @override
+ String get filenameShowAdvancedTagsDescription =>
+ 'Enable formatted tags for track padding and date patterns';
+
@override
String get folderOrganizationNone => 'No organization';
@@ -5779,6 +6636,12 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
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';
@@ -6110,6 +6973,17 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
+ @override
+ String libraryTracksUnit(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return '$_temp0';
+ }
+
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -6235,7 +7109,8 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
'Download music from Spotify, Deezer, or paste any supported URL';
@override
- String get tutorialWelcomeTip2 => '從 Tidal、Qobuz 或 Deezer 取得 FLAC 品質音訊';
+ String get tutorialWelcomeTip2 =>
+ 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
@@ -6552,6 +7427,210 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get trackConvertFailed => 'Conversion failed';
+ @override
+ String get actionCreate => 'Create';
+
+ @override
+ String get collectionFoldersTitle => 'My folders';
+
+ @override
+ String get collectionWishlist => 'Wishlist';
+
+ @override
+ String get collectionLoved => 'Loved';
+
+ @override
+ String get collectionPlaylists => 'Playlists';
+
+ @override
+ String get collectionPlaylist => 'Playlist';
+
+ @override
+ String get collectionAddToPlaylist => 'Add to playlist';
+
+ @override
+ String get collectionCreatePlaylist => 'Create playlist';
+
+ @override
+ String get collectionNoPlaylistsYet => 'No playlists yet';
+
+ @override
+ String get collectionNoPlaylistsSubtitle =>
+ 'Create a playlist to start categorizing tracks';
+
+ @override
+ String collectionPlaylistTracks(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: '$count tracks',
+ one: '1 track',
+ );
+ return '$_temp0';
+ }
+
+ @override
+ String collectionAddedToPlaylist(String playlistName) {
+ return 'Added to \"$playlistName\"';
+ }
+
+ @override
+ String collectionAlreadyInPlaylist(String playlistName) {
+ return 'Already in \"$playlistName\"';
+ }
+
+ @override
+ String get collectionPlaylistCreated => 'Playlist created';
+
+ @override
+ String get collectionPlaylistNameHint => 'Playlist name';
+
+ @override
+ String get collectionPlaylistNameRequired => 'Playlist name is required';
+
+ @override
+ String get collectionRenamePlaylist => 'Rename playlist';
+
+ @override
+ String get collectionDeletePlaylist => 'Delete playlist';
+
+ @override
+ String collectionDeletePlaylistMessage(String playlistName) {
+ return 'Delete \"$playlistName\" and all tracks inside it?';
+ }
+
+ @override
+ String get collectionPlaylistDeleted => 'Playlist deleted';
+
+ @override
+ String get collectionPlaylistRenamed => 'Playlist renamed';
+
+ @override
+ String get collectionWishlistEmptyTitle => 'Wishlist is empty';
+
+ @override
+ String get collectionWishlistEmptySubtitle =>
+ 'Tap + on tracks to save what you want to download later';
+
+ @override
+ String get collectionLovedEmptyTitle => 'Loved folder is empty';
+
+ @override
+ String get collectionLovedEmptySubtitle =>
+ 'Tap love on tracks to keep your favorites';
+
+ @override
+ String get collectionPlaylistEmptyTitle => 'Playlist is empty';
+
+ @override
+ String get collectionPlaylistEmptySubtitle =>
+ 'Long-press + on any track to add it here';
+
+ @override
+ String get collectionRemoveFromPlaylist => 'Remove from playlist';
+
+ @override
+ String get collectionRemoveFromFolder => 'Remove from folder';
+
+ @override
+ String collectionRemoved(String trackName) {
+ return '\"$trackName\" removed';
+ }
+
+ @override
+ String collectionAddedToLoved(String trackName) {
+ return '\"$trackName\" added to Loved';
+ }
+
+ @override
+ String collectionRemovedFromLoved(String trackName) {
+ return '\"$trackName\" removed from Loved';
+ }
+
+ @override
+ String collectionAddedToWishlist(String trackName) {
+ return '\"$trackName\" added to Wishlist';
+ }
+
+ @override
+ String collectionRemovedFromWishlist(String trackName) {
+ return '\"$trackName\" removed from Wishlist';
+ }
+
+ @override
+ String get trackOptionAddToLoved => 'Add to Loved';
+
+ @override
+ String get trackOptionRemoveFromLoved => 'Remove from Loved';
+
+ @override
+ String get trackOptionAddToWishlist => 'Add to Wishlist';
+
+ @override
+ String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
+
+ @override
+ String get collectionPlaylistChangeCover => 'Change cover image';
+
+ @override
+ String get collectionPlaylistRemoveCover => 'Remove cover image';
+
+ @override
+ String selectionShareCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Share $count $_temp0';
+ }
+
+ @override
+ String get selectionShareNoFiles => 'No shareable files found';
+
+ @override
+ String selectionConvertCount(int count) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0';
+ }
+
+ @override
+ String get selectionConvertNoConvertible => 'No convertible tracks selected';
+
+ @override
+ String get selectionBatchConvertConfirmTitle => 'Batch Convert';
+
+ @override
+ String selectionBatchConvertConfirmMessage(
+ int count,
+ String format,
+ String bitrate,
+ ) {
+ String _temp0 = intl.Intl.pluralLogic(
+ count,
+ locale: localeName,
+ other: 'tracks',
+ one: 'track',
+ );
+ return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
+ }
+
+ @override
+ String selectionBatchConvertProgress(int current, int total) {
+ return 'Converting $current of $total...';
+ }
+
+ @override
+ String selectionBatchConvertSuccess(int success, int total, String format) {
+ return 'Converted $success of $total tracks to $format';
+ }
+
@override
String downloadedAlbumDownloadedCount(int count) {
return '$count downloaded';
diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb
index bdc91aa3..3dce0557 100644
--- a/lib/l10n/arb/app_de.arb
+++ b/lib/l10n/arb/app_de.arb
@@ -728,7 +728,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
- "dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {}=1{Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.",
+ "dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -807,7 +807,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
- "snackbarDeletedTracks": "{count} {count, plural, one {}=1{Titel} other{Titel}}",
+ "snackbarDeletedTracks": "{count} {count, plural, one {Titel} other{Titel}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1909,7 +1909,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
- "downloadedAlbumDeleteMessage": "{count} {count, plural, one {}=1{Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.",
+ "downloadedAlbumDeleteMessage": "{count} {count, plural, one {Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -1935,7 +1935,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
- "downloadedAlbumDeleteCount": "Lösche {count} {count, plural, one {}=1{Titel}other{Titel}}",
+ "downloadedAlbumDeleteCount": "Lösche {count} {count, plural, one {Titel} other{Titel}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
@@ -2373,7 +2373,7 @@
"@timeJustNow": {
"description": "Relative time - less than a minute ago"
},
- "timeMinutesAgo": "{count, plural, one {}=1{vor 1 Minute} other{vor {count} Minuten}}",
+ "timeMinutesAgo": "{count, plural, one {vor {count} Minute} other{vor {count} Minuten}}",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
@@ -2382,7 +2382,7 @@
}
}
},
- "timeHoursAgo": "{count, plural, one {}=1{vor 1 Stunde} other{vor {count} Stunden}}",
+ "timeHoursAgo": "{count, plural, one {vor {count} Stunde} other{vor {count} Stunden}}",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
@@ -3117,7 +3117,7 @@
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
- "selectionShareCount": "Teile {count} {count, plural, one {}=1{Titel}other{Titel}}",
+ "selectionShareCount": "Teile {count} {count, plural, one {Titel} other{Titel}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
@@ -3130,7 +3130,7 @@
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
- "selectionConvertCount": "Konvertiere {count} {count, plural, one {}=1{Titel}other{Titel}}",
+ "selectionConvertCount": "Konvertiere {count} {count, plural, one {Titel} other{Titel}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
@@ -3147,7 +3147,7 @@
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
- "selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, one {}=1{Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.",
+ "selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, one {Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb
index 03313d7b..7e882ab2 100644
--- a/lib/l10n/arb/app_en.arb
+++ b/lib/l10n/arb/app_en.arb
@@ -671,6 +671,10 @@
"@dialogImport": {
"description": "Dialog button - import data"
},
+ "dialogDownload": "Download",
+ "@dialogDownload": {
+ "description": "Dialog button - download action"
+ },
"dialogDiscard": "Discard",
"@dialogDiscard": {
"description": "Dialog button - discard changes"
@@ -1570,7 +1574,59 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
- "extensionDefaultProvider": "Default (Deezer/Spotify)",
+ "storeAddRepoTitle": "Add Extension Repository",
+ "@storeAddRepoTitle": {
+ "description": "Store setup screen - heading when no repo is configured"
+ },
+ "storeAddRepoDescription": "Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.",
+ "@storeAddRepoDescription": {
+ "description": "Store setup screen - explanatory text"
+ },
+ "storeRepoUrlLabel": "Repository URL",
+ "@storeRepoUrlLabel": {
+ "description": "Label for the repository URL input field"
+ },
+ "storeRepoUrlHint": "https://github.com/user/repo",
+ "@storeRepoUrlHint": {
+ "description": "Hint/placeholder for the repository URL input field"
+ },
+ "storeRepoUrlHelper": "e.g. https://github.com/user/extensions-repo",
+ "@storeRepoUrlHelper": {
+ "description": "Helper text below the repository URL input field"
+ },
+ "storeAddRepoButton": "Add Repository",
+ "@storeAddRepoButton": {
+ "description": "Button to submit a new repository URL"
+ },
+ "storeChangeRepoTooltip": "Change repository",
+ "@storeChangeRepoTooltip": {
+ "description": "Tooltip for the change-repository icon button in the app bar"
+ },
+ "storeRepoDialogTitle": "Extension Repository",
+ "@storeRepoDialogTitle": {
+ "description": "Title of the change/remove repository dialog"
+ },
+ "storeRepoDialogCurrent": "Current repository:",
+ "@storeRepoDialogCurrent": {
+ "description": "Label shown above the current repository URL in the dialog"
+ },
+ "storeNewRepoUrlLabel": "New Repository URL",
+ "@storeNewRepoUrlLabel": {
+ "description": "Label for the new repository URL field inside the dialog"
+ },
+ "storeLoadError": "Failed to load store",
+ "@storeLoadError": {
+ "description": "Error heading when the store cannot be loaded"
+ },
+ "storeEmptyNoExtensions": "No extensions available",
+ "@storeEmptyNoExtensions": {
+ "description": "Message when store has no extensions"
+ },
+ "storeEmptyNoResults": "No extensions found",
+ "@storeEmptyNoResults": {
+ "description": "Message when search/filter returns no results"
+ },
+ "extensionDefaultProvider": "Default (Deezer)",
"@extensionDefaultProvider": {
"description": "Default search provider option"
},
@@ -1769,6 +1825,46 @@
"@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max"
},
+ "downloadLossy320": "Lossy 320kbps",
+ "@downloadLossy320": {
+ "description": "Quality option label for Tidal lossy 320kbps"
+ },
+ "downloadLossyFormat": "Lossy Format",
+ "@downloadLossyFormat": {
+ "description": "Setting title to pick output format for Tidal lossy downloads"
+ },
+ "downloadLossy320Format": "Lossy 320kbps Format",
+ "@downloadLossy320Format": {
+ "description": "Title of the Tidal lossy format picker bottom sheet"
+ },
+ "downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
+ "@downloadLossy320FormatDesc": {
+ "description": "Description in the Tidal lossy format picker"
+ },
+ "downloadLossyMp3": "MP3 320kbps",
+ "@downloadLossyMp3": {
+ "description": "Tidal lossy format option - MP3 320kbps"
+ },
+ "downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
+ "@downloadLossyMp3Subtitle": {
+ "description": "Subtitle for MP3 320kbps Tidal lossy option"
+ },
+ "downloadLossyOpus256": "Opus 256kbps",
+ "@downloadLossyOpus256": {
+ "description": "Tidal lossy format option - Opus 256kbps"
+ },
+ "downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
+ "@downloadLossyOpus256Subtitle": {
+ "description": "Subtitle for Opus 256kbps Tidal lossy option"
+ },
+ "downloadLossyOpus128": "Opus 128kbps",
+ "@downloadLossyOpus128": {
+ "description": "Tidal lossy format option - Opus 128kbps"
+ },
+ "downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
+ "@downloadLossyOpus128Subtitle": {
+ "description": "Subtitle for Opus 128kbps Tidal lossy option"
+ },
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {
"description": "Note about quality availability"
@@ -2186,6 +2282,30 @@
"@libraryShowDuplicateIndicatorSubtitle": {
"description": "Subtitle for duplicate indicator toggle"
},
+ "libraryAutoScan": "Auto Scan",
+ "@libraryAutoScan": {
+ "description": "Setting for automatic library scanning"
+ },
+ "libraryAutoScanSubtitle": "Automatically scan your library for new files",
+ "@libraryAutoScanSubtitle": {
+ "description": "Subtitle for auto scan setting"
+ },
+ "libraryAutoScanOff": "Off",
+ "@libraryAutoScanOff": {
+ "description": "Auto scan disabled"
+ },
+ "libraryAutoScanOnOpen": "Every app open",
+ "@libraryAutoScanOnOpen": {
+ "description": "Auto scan when app opens"
+ },
+ "libraryAutoScanDaily": "Daily",
+ "@libraryAutoScanDaily": {
+ "description": "Auto scan once per day"
+ },
+ "libraryAutoScanWeekly": "Weekly",
+ "@libraryAutoScanWeekly": {
+ "description": "Auto scan once per week"
+ },
"libraryActions": "Actions",
"@libraryActions": {
"description": "Section header for library actions"
@@ -2763,6 +2883,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
+ "queueFlacAction": "Queue FLAC",
+ "@queueFlacAction": {
+ "description": "Action/button label for queueing FLAC redownloads for local tracks"
+ },
+ "queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected",
+ "@queueFlacConfirmMessage": {
+ "description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})",
+ "@queueFlacFindingProgress": {
+ "description": "Snackbar while resolving remote matches for local FLAC redownloads",
+ "placeholders": {
+ "current": {
+ "type": "int"
+ },
+ "total": {
+ "type": "int"
+ }
+ }
+ },
+ "queueFlacNoReliableMatches": "No reliable online matches found for the selection",
+ "@queueFlacNoReliableMatches": {
+ "description": "Snackbar when no safe FLAC redownload matches were found"
+ },
+ "queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}",
+ "@queueFlacQueuedWithSkipped": {
+ "description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
+ "placeholders": {
+ "addedCount": {
+ "type": "int"
+ },
+ "skippedCount": {
+ "type": "int"
+ }
+ }
+ },
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2776,7 +2937,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
- "trackConvertFormatSubtitle": "Convert to MP3 or Opus",
+ "trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2811,6 +2972,22 @@
}
}
},
+ "trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
+ "@trackConvertConfirmMessageLossless": {
+ "description": "Confirmation dialog message for lossless-to-lossless conversion",
+ "placeholders": {
+ "sourceFormat": {
+ "type": "String"
+ },
+ "targetFormat": {
+ "type": "String"
+ }
+ }
+ },
+ "trackConvertLosslessHint": "Lossless conversion — no quality loss",
+ "@trackConvertLosslessHint": {
+ "description": "Hint shown when converting between lossless formats"
+ },
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3162,6 +3339,18 @@
}
}
},
+ "selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
+ "@selectionBatchConvertConfirmMessageLossless": {
+ "description": "Confirmation dialog message for lossless batch conversion",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ },
+ "format": {
+ "type": "String"
+ }
+ }
+ },
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
@@ -3205,5 +3394,514 @@
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
+ },
+
+ "lyricsProvidersTitle": "Lyrics Providers",
+ "@lyricsProvidersTitle": {
+ "description": "Title for the lyrics provider priority page"
+ },
+ "lyricsProvidersDescription": "Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.",
+ "@lyricsProvidersDescription": {
+ "description": "Description on the lyrics provider priority page"
+ },
+ "lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
+ "@lyricsProvidersInfoText": {
+ "description": "Info tip on lyrics provider priority page"
+ },
+ "lyricsProvidersEnabledSection": "Enabled ({count})",
+ "@lyricsProvidersEnabledSection": {
+ "description": "Section header for enabled providers",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "lyricsProvidersDisabledSection": "Disabled ({count})",
+ "@lyricsProvidersDisabledSection": {
+ "description": "Section header for disabled providers",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "lyricsProvidersAtLeastOne": "At least one provider must remain enabled",
+ "@lyricsProvidersAtLeastOne": {
+ "description": "Snackbar when user tries to disable the last enabled provider"
+ },
+ "lyricsProvidersSaved": "Lyrics provider priority saved",
+ "@lyricsProvidersSaved": {
+ "description": "Snackbar after saving lyrics provider priority"
+ },
+ "lyricsProvidersDiscardContent": "You have unsaved changes that will be lost.",
+ "@lyricsProvidersDiscardContent": {
+ "description": "Body text of the discard-changes dialog on lyrics provider page"
+ },
+ "lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
+ "@lyricsProviderSpotifyApiDesc": {
+ "description": "Description for Spotify Lyrics API provider"
+ },
+ "lyricsProviderLrclibDesc": "Open-source synced lyrics database",
+ "@lyricsProviderLrclibDesc": {
+ "description": "Description for LRCLIB provider"
+ },
+ "lyricsProviderNeteaseDesc": "NetEase Cloud Music (good for Asian songs)",
+ "@lyricsProviderNeteaseDesc": {
+ "description": "Description for Netease provider"
+ },
+ "lyricsProviderMusixmatchDesc": "Largest lyrics database (multi-language)",
+ "@lyricsProviderMusixmatchDesc": {
+ "description": "Description for Musixmatch provider"
+ },
+ "lyricsProviderAppleMusicDesc": "Word-by-word synced lyrics (via proxy)",
+ "@lyricsProviderAppleMusicDesc": {
+ "description": "Description for Apple Music provider"
+ },
+ "lyricsProviderQqMusicDesc": "QQ Music (good for Chinese songs, via proxy)",
+ "@lyricsProviderQqMusicDesc": {
+ "description": "Description for QQ Music provider"
+ },
+ "lyricsProviderExtensionDesc": "Extension provider",
+ "@lyricsProviderExtensionDesc": {
+ "description": "Generic description for extension-based lyrics providers"
+ },
+
+ "safMigrationTitle": "Storage Update Required",
+ "@safMigrationTitle": {
+ "description": "Title of SAF migration dialog"
+ },
+ "safMigrationMessage1": "SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.",
+ "@safMigrationMessage1": {
+ "description": "First paragraph of SAF migration dialog"
+ },
+ "safMigrationMessage2": "Please select your download folder again to switch to the new storage system.",
+ "@safMigrationMessage2": {
+ "description": "Second paragraph of SAF migration dialog"
+ },
+ "safMigrationSuccess": "Download folder updated to SAF mode",
+ "@safMigrationSuccess": {
+ "description": "Snackbar after successfully migrating to SAF"
+ },
+
+ "settingsDonate": "Donate",
+ "@settingsDonate": {
+ "description": "Settings menu item - donate"
+ },
+ "settingsDonateSubtitle": "Support SpotiFLAC-Mobile development",
+ "@settingsDonateSubtitle": {
+ "description": "Subtitle for donate menu item"
+ },
+
+ "tooltipLoveAll": "Love All",
+ "@tooltipLoveAll": {
+ "description": "Tooltip for the Love All button on album/playlist screens"
+ },
+ "tooltipAddToPlaylist": "Add to Playlist",
+ "@tooltipAddToPlaylist": {
+ "description": "Tooltip for the Add to Playlist button"
+ },
+ "snackbarRemovedTracksFromLoved": "Removed {count} tracks from Loved",
+ "@snackbarRemovedTracksFromLoved": {
+ "description": "Snackbar after removing multiple tracks from Loved folder",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "snackbarAddedTracksToLoved": "Added {count} tracks to Loved",
+ "@snackbarAddedTracksToLoved": {
+ "description": "Snackbar after adding multiple tracks to Loved folder",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+
+ "dialogDownloadAllTitle": "Download All",
+ "@dialogDownloadAllTitle": {
+ "description": "Title of the Download All confirmation dialog"
+ },
+ "dialogDownloadAllMessage": "Download {count} tracks?",
+ "@dialogDownloadAllMessage": {
+ "description": "Body of the Download All confirmation dialog",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "dialogDownload": "Download",
+ "@dialogDownload": {
+ "description": "Confirm button in Download All dialog"
+ },
+
+ "homeSkipAlreadyDownloaded": "Skip already downloaded songs",
+ "@homeSkipAlreadyDownloaded": {
+ "description": "Checkbox label in import dialog to skip already-downloaded songs"
+ },
+ "homeGoToAlbum": "Go to Album",
+ "@homeGoToAlbum": {
+ "description": "Context menu item to navigate to the album page"
+ },
+ "homeAlbumInfoUnavailable": "Album info not available",
+ "@homeAlbumInfoUnavailable": {
+ "description": "Snackbar when album info cannot be loaded"
+ },
+
+ "snackbarLoadingCueSheet": "Loading CUE sheet...",
+ "@snackbarLoadingCueSheet": {
+ "description": "Snackbar while loading a CUE sheet file"
+ },
+ "snackbarMetadataSaved": "Metadata saved successfully",
+ "@snackbarMetadataSaved": {
+ "description": "Snackbar after successfully saving track metadata"
+ },
+ "snackbarFailedToEmbedLyrics": "Failed to embed lyrics",
+ "@snackbarFailedToEmbedLyrics": {
+ "description": "Snackbar when lyrics embedding fails"
+ },
+ "snackbarFailedToWriteStorage": "Failed to write back to storage",
+ "@snackbarFailedToWriteStorage": {
+ "description": "Snackbar when writing metadata back to file fails"
+ },
+ "snackbarError": "Error: {error}",
+ "@snackbarError": {
+ "description": "Generic error snackbar with error detail",
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "snackbarNoActionDefined": "No action defined for this button",
+ "@snackbarNoActionDefined": {
+ "description": "Snackbar when an extension button has no action configured"
+ },
+
+ "noTracksFoundForAlbum": "No tracks found for this album",
+ "@noTracksFoundForAlbum": {
+ "description": "Empty state message when an album has no tracks"
+ },
+
+ "downloadLocationSubtitle": "Choose storage mode for downloaded files.",
+ "@downloadLocationSubtitle": {
+ "description": "Subtitle text in Android download location bottom sheet"
+ },
+ "storageModeAppFolder": "App folder (non-SAF)",
+ "@storageModeAppFolder": {
+ "description": "Storage mode option - use legacy app folder"
+ },
+ "storageModeAppFolderSubtitle": "Use default Music/SpotiFLAC path",
+ "@storageModeAppFolderSubtitle": {
+ "description": "Subtitle for app folder storage mode"
+ },
+ "storageModeSaf": "SAF folder",
+ "@storageModeSaf": {
+ "description": "Storage mode option - use Android SAF picker"
+ },
+ "storageModeSafSubtitle": "Pick folder via Android Storage Access Framework",
+ "@storageModeSafSubtitle": {
+ "description": "Subtitle for SAF storage mode"
+ },
+ "downloadFilenameDescription": "Customize how your files are named.",
+ "@downloadFilenameDescription": {
+ "description": "Description text in filename format bottom sheet"
+ },
+ "downloadFilenameInsertTag": "Tap to insert tag:",
+ "@downloadFilenameInsertTag": {
+ "description": "Label above filename tag chips"
+ },
+ "downloadSeparateSinglesEnabled": "Albums/ and Singles/ folders",
+ "@downloadSeparateSinglesEnabled": {
+ "description": "Subtitle when separate singles folder is enabled"
+ },
+ "downloadSeparateSinglesDisabled": "All files in same structure",
+ "@downloadSeparateSinglesDisabled": {
+ "description": "Subtitle when separate singles folder is disabled"
+ },
+ "downloadArtistNameFilters": "Artist Name Filters",
+ "@downloadArtistNameFilters": {
+ "description": "Setting title for artist folder filter options"
+ },
+ "downloadCreatePlaylistSourceFolder": "Create playlist source folder",
+ "@downloadCreatePlaylistSourceFolder": {
+ "description": "Setting title for adding a playlist folder prefix before the normal organization structure"
+ },
+ "downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.",
+ "@downloadCreatePlaylistSourceFolderEnabled": {
+ "description": "Subtitle when playlist source folder prefix is enabled"
+ },
+ "downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.",
+ "@downloadCreatePlaylistSourceFolderDisabled": {
+ "description": "Subtitle when playlist source folder prefix is disabled"
+ },
+ "downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.",
+ "@downloadCreatePlaylistSourceFolderRedundant": {
+ "description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
+ },
+ "downloadSongLinkRegion": "SongLink Region",
+ "@downloadSongLinkRegion": {
+ "description": "Setting title for SongLink country region"
+ },
+ "downloadNetworkCompatibilityMode": "Network compatibility mode",
+ "@downloadNetworkCompatibilityMode": {
+ "description": "Setting title for network compatibility toggle"
+ },
+ "downloadNetworkCompatibilityModeEnabled": "Enabled: try HTTP + accept invalid TLS certificates (unsafe)",
+ "@downloadNetworkCompatibilityModeEnabled": {
+ "description": "Subtitle when network compatibility mode is enabled"
+ },
+ "downloadNetworkCompatibilityModeDisabled": "Off: strict HTTPS certificate validation (recommended)",
+ "@downloadNetworkCompatibilityModeDisabled": {
+ "description": "Subtitle when network compatibility mode is disabled"
+ },
+ "downloadSelectServiceToEnable": "Select a built-in service to enable",
+ "@downloadSelectServiceToEnable": {
+ "description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
+ },
+
+ "downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality",
+ "@downloadSelectTidalQobuz": {
+ "description": "Info hint when non-Tidal/Qobuz service is selected"
+ },
+ "downloadEmbedLyricsDisabled": "Disabled while Embed Metadata is turned off",
+ "@downloadEmbedLyricsDisabled": {
+ "description": "Subtitle for Embed Lyrics when Embed Metadata is disabled"
+ },
+ "downloadNeteaseIncludeTranslation": "Netease: Include Translation",
+ "@downloadNeteaseIncludeTranslation": {
+ "description": "Toggle title for including Netease translated lyrics"
+ },
+ "downloadNeteaseIncludeTranslationEnabled": "Append translated lyrics when available",
+ "@downloadNeteaseIncludeTranslationEnabled": {
+ "description": "Subtitle when Netease translation is enabled"
+ },
+ "downloadNeteaseIncludeTranslationDisabled": "Use original lyrics only",
+ "@downloadNeteaseIncludeTranslationDisabled": {
+ "description": "Subtitle when Netease translation is disabled"
+ },
+ "downloadNeteaseIncludeRomanization": "Netease: Include Romanization",
+ "@downloadNeteaseIncludeRomanization": {
+ "description": "Toggle title for including Netease romanized lyrics"
+ },
+ "downloadNeteaseIncludeRomanizationEnabled": "Append romanized lyrics when available",
+ "@downloadNeteaseIncludeRomanizationEnabled": {
+ "description": "Subtitle when Netease romanization is enabled"
+ },
+ "downloadNeteaseIncludeRomanizationDisabled": "Disabled",
+ "@downloadNeteaseIncludeRomanizationDisabled": {
+ "description": "Subtitle when Netease romanization is disabled"
+ },
+ "downloadAppleQqMultiPerson": "Apple/QQ Multi-Person Word-by-Word",
+ "@downloadAppleQqMultiPerson": {
+ "description": "Toggle title for Apple/QQ multi-person word-by-word lyrics"
+ },
+ "downloadAppleQqMultiPersonEnabled": "Enable v1/v2 speaker and [bg:] tags",
+ "@downloadAppleQqMultiPersonEnabled": {
+ "description": "Subtitle when multi-person word-by-word is enabled"
+ },
+ "downloadAppleQqMultiPersonDisabled": "Simplified word-by-word formatting",
+ "@downloadAppleQqMultiPersonDisabled": {
+ "description": "Subtitle when multi-person word-by-word is disabled"
+ },
+ "downloadMusixmatchLanguage": "Musixmatch Language",
+ "@downloadMusixmatchLanguage": {
+ "description": "Setting title for Musixmatch language preference"
+ },
+ "downloadMusixmatchLanguageAuto": "Auto (original)",
+ "@downloadMusixmatchLanguageAuto": {
+ "description": "Option label when Musixmatch uses original language"
+ },
+ "downloadFilterContributing": "Filter contributing artists in Album Artist",
+ "@downloadFilterContributing": {
+ "description": "Toggle title for filtering contributing artists in Album Artist metadata"
+ },
+ "downloadFilterContributingEnabled": "Album Artist metadata uses primary artist only",
+ "@downloadFilterContributingEnabled": {
+ "description": "Subtitle when contributing artist filter is enabled"
+ },
+ "downloadFilterContributingDisabled": "Keep full Album Artist metadata value",
+ "@downloadFilterContributingDisabled": {
+ "description": "Subtitle when contributing artist filter is disabled"
+ },
+
+ "downloadProvidersNoneEnabled": "None enabled",
+ "@downloadProvidersNoneEnabled": {
+ "description": "Subtitle for lyrics providers setting when no providers are enabled"
+ },
+ "downloadMusixmatchLanguageCode": "Language code",
+ "@downloadMusixmatchLanguageCode": {
+ "description": "Label for the Musixmatch language code text field"
+ },
+ "downloadMusixmatchLanguageHint": "auto / en / es / ja",
+ "@downloadMusixmatchLanguageHint": {
+ "description": "Hint text for the Musixmatch language code field"
+ },
+ "downloadMusixmatchLanguageDesc": "Set preferred language code (example: en, es, ja). Leave empty for auto.",
+ "@downloadMusixmatchLanguageDesc": {
+ "description": "Description in the Musixmatch language picker"
+ },
+ "downloadMusixmatchAuto": "Auto",
+ "@downloadMusixmatchAuto": {
+ "description": "Button to reset Musixmatch language to automatic"
+ },
+
+ "downloadNetworkAnySubtitle": "WiFi + Mobile Data",
+ "@downloadNetworkAnySubtitle": {
+ "description": "Subtitle for 'Any' network mode option"
+ },
+ "downloadNetworkWifiOnlySubtitle": "Pause downloads on mobile data",
+ "@downloadNetworkWifiOnlySubtitle": {
+ "description": "Subtitle for 'WiFi only' network mode option"
+ },
+ "downloadSongLinkRegionDesc": "Used as userCountry for SongLink API lookup.",
+ "@downloadSongLinkRegionDesc": {
+ "description": "Description in the SongLink region picker"
+ },
+ "downloadFolderOrganization": "Folder Organization",
+ "@downloadFolderOrganization": {
+ "description": "Title of the folder organization picker bottom sheet"
+ },
+ "snackbarUnsupportedAudioFormat": "Unsupported audio format",
+ "@snackbarUnsupportedAudioFormat": {
+ "description": "Snackbar when the audio format is not supported for the requested operation"
+ },
+ "cacheRefresh": "Refresh",
+ "@cacheRefresh": {
+ "description": "Tooltip for refresh button on cache management page"
+ },
+ "dialogDownloadAllTitle": "Download All",
+ "@dialogDownloadAllTitle": {
+ "description": "Dialog title for bulk download confirmation"
+ },
+ "dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?",
+ "@dialogDownloadPlaylistsMessage": {
+ "description": "Dialog message for bulk playlist download confirmation",
+ "placeholders": {
+ "trackCount": {
+ "type": "int"
+ },
+ "playlistCount": {
+ "type": "int"
+ }
+ }
+ },
+ "bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}",
+ "@bulkDownloadPlaylistsButton": {
+ "description": "Button label for bulk downloading selected playlists",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "bulkDownloadSelectPlaylists": "Select playlists to download",
+ "@bulkDownloadSelectPlaylists": {
+ "description": "Button label when no playlists are selected for download"
+ },
+ "snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks",
+ "@snackbarSelectedPlaylistsEmpty": {
+ "description": "Snackbar when selected playlists contain no tracks"
+ },
+ "playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}",
+ "@playlistsCount": {
+ "description": "Playlist count display",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "editMetadataAutoFill": "Auto-fill from online",
+ "@editMetadataAutoFill": {
+ "description": "Section title for selective online metadata auto-fill in the edit metadata sheet"
+ },
+ "editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
+ "@editMetadataAutoFillDesc": {
+ "description": "Description for the auto-fill section"
+ },
+ "editMetadataAutoFillFetch": "Fetch & Fill",
+ "@editMetadataAutoFillFetch": {
+ "description": "Button label to fetch online metadata and fill selected fields"
+ },
+ "editMetadataAutoFillSearching": "Searching online...",
+ "@editMetadataAutoFillSearching": {
+ "description": "Snackbar shown while searching for online metadata"
+ },
+ "editMetadataAutoFillNoResults": "No matching metadata found online",
+ "@editMetadataAutoFillNoResults": {
+ "description": "Snackbar when online metadata search returns no results"
+ },
+ "editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata",
+ "@editMetadataAutoFillDone": {
+ "description": "Snackbar confirming how many fields were auto-filled",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill",
+ "@editMetadataAutoFillNoneSelected": {
+ "description": "Snackbar when user taps Fetch without selecting any fields"
+ },
+ "editMetadataFieldTitle": "Title",
+ "@editMetadataFieldTitle": {
+ "description": "Chip label for title field in auto-fill selector"
+ },
+ "editMetadataFieldArtist": "Artist",
+ "@editMetadataFieldArtist": {
+ "description": "Chip label for artist field in auto-fill selector"
+ },
+ "editMetadataFieldAlbum": "Album",
+ "@editMetadataFieldAlbum": {
+ "description": "Chip label for album field in auto-fill selector"
+ },
+ "editMetadataFieldAlbumArtist": "Album Artist",
+ "@editMetadataFieldAlbumArtist": {
+ "description": "Chip label for album artist field in auto-fill selector"
+ },
+ "editMetadataFieldDate": "Date",
+ "@editMetadataFieldDate": {
+ "description": "Chip label for date field in auto-fill selector"
+ },
+ "editMetadataFieldTrackNum": "Track #",
+ "@editMetadataFieldTrackNum": {
+ "description": "Chip label for track number field in auto-fill selector"
+ },
+ "editMetadataFieldDiscNum": "Disc #",
+ "@editMetadataFieldDiscNum": {
+ "description": "Chip label for disc number field in auto-fill selector"
+ },
+ "editMetadataFieldGenre": "Genre",
+ "@editMetadataFieldGenre": {
+ "description": "Chip label for genre field in auto-fill selector"
+ },
+ "editMetadataFieldIsrc": "ISRC",
+ "@editMetadataFieldIsrc": {
+ "description": "Chip label for ISRC field in auto-fill selector"
+ },
+ "editMetadataFieldLabel": "Label",
+ "@editMetadataFieldLabel": {
+ "description": "Chip label for label field in auto-fill selector"
+ },
+ "editMetadataFieldCopyright": "Copyright",
+ "@editMetadataFieldCopyright": {
+ "description": "Chip label for copyright field in auto-fill selector"
+ },
+ "editMetadataFieldCover": "Cover Art",
+ "@editMetadataFieldCover": {
+ "description": "Chip label for cover art field in auto-fill selector"
+ },
+ "editMetadataSelectAll": "All",
+ "@editMetadataSelectAll": {
+ "description": "Button to select all fields for auto-fill"
+ },
+ "editMetadataSelectEmpty": "Empty only",
+ "@editMetadataSelectEmpty": {
+ "description": "Button to select only fields that are currently empty"
}
}
diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb
index 970b6d82..eba93ff1 100644
--- a/lib/l10n/arb/app_id.arb
+++ b/lib/l10n/arb/app_id.arb
@@ -897,15 +897,15 @@
"@errorNoTracksFound": {
"description": "Error - search returned no results"
},
- "errorUrlNotRecognized": "Tautan tidak dikenali",
+ "errorUrlNotRecognized": "Link tidak dikenali",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
- "errorUrlNotRecognizedMessage": "Tautan ini tidak didukung. Pastikan URL sudah benar dan ekstensi yang kompatibel telah terpasang.",
+ "errorUrlNotRecognizedMessage": "Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
- "errorUrlFetchFailed": "Konten dari tautan ini gagal dimuat. Silakan coba lagi.",
+ "errorUrlFetchFailed": "Gagal memuat konten dari link ini. Silakan coba lagi.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
@@ -1805,6 +1805,22 @@
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
+ "downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist",
+ "@downloadCreatePlaylistSourceFolder": {
+ "description": "Setting title for adding a playlist folder prefix before the normal organization structure"
+ },
+ "downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.",
+ "@downloadCreatePlaylistSourceFolderEnabled": {
+ "description": "Subtitle when playlist source folder prefix is enabled"
+ },
+ "downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.",
+ "@downloadCreatePlaylistSourceFolderDisabled": {
+ "description": "Subtitle when playlist source folder prefix is disabled"
+ },
+ "downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.",
+ "@downloadCreatePlaylistSourceFolderRedundant": {
+ "description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
+ },
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
"@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name"
@@ -2763,6 +2779,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
+ "queueFlacAction": "Antrekan FLAC",
+ "@queueFlacAction": {
+ "description": "Action/button label for queueing FLAC redownloads for local tracks"
+ },
+ "queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
+ "@queueFlacConfirmMessage": {
+ "description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
+ "@queueFlacFindingProgress": {
+ "description": "Snackbar while resolving remote matches for local FLAC redownloads",
+ "placeholders": {
+ "current": {
+ "type": "int"
+ },
+ "total": {
+ "type": "int"
+ }
+ }
+ },
+ "queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
+ "@queueFlacNoReliableMatches": {
+ "description": "Snackbar when no safe FLAC redownload matches were found"
+ },
+ "queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
+ "@queueFlacQueuedWithSkipped": {
+ "description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
+ "placeholders": {
+ "addedCount": {
+ "type": "int"
+ },
+ "skippedCount": {
+ "type": "int"
+ }
+ }
+ },
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2776,7 +2833,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
- "trackConvertFormatSubtitle": "Convert to MP3 or Opus",
+ "trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2811,6 +2868,22 @@
}
}
},
+ "trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
+ "@trackConvertConfirmMessageLossless": {
+ "description": "Confirmation dialog message for lossless-to-lossless conversion",
+ "placeholders": {
+ "sourceFormat": {
+ "type": "String"
+ },
+ "targetFormat": {
+ "type": "String"
+ }
+ }
+ },
+ "trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
+ "@trackConvertLosslessHint": {
+ "description": "Hint shown when converting between lossless formats"
+ },
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3206,4 +3279,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
-}
\ No newline at end of file
+}
diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb
index 665fb473..4a1ffc71 100644
--- a/lib/l10n/arb/app_ru.arb
+++ b/lib/l10n/arb/app_ru.arb
@@ -728,7 +728,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
- "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
+ "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -807,7 +807,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
- "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
+ "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1370,7 +1370,7 @@
}
}
},
- "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
+ "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
@@ -1909,7 +1909,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
- "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
+ "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -1935,7 +1935,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
- "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
+ "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
@@ -2234,7 +2234,7 @@
"@libraryAboutDescription": {
"description": "Description of local library feature"
},
- "libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
+ "libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
@@ -2373,7 +2373,7 @@
"@timeJustNow": {
"description": "Relative time - less than a minute ago"
},
- "timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} =1 {1 минуту} other {{count} минут}} назад",
+ "timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} other {{count} минут}} назад",
"@timeMinutesAgo": {
"description": "Relative time - minutes ago",
"placeholders": {
@@ -2382,7 +2382,7 @@
}
}
},
- "timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} =1 {1 час} other {{count} часов}} назад",
+ "timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} other {{count} часов}} назад",
"@timeHoursAgo": {
"description": "Relative time - hours ago",
"placeholders": {
@@ -2952,7 +2952,7 @@
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
- "collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
+ "collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
@@ -3117,7 +3117,7 @@
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
- "selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
+ "selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
@@ -3130,7 +3130,7 @@
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
- "selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
+ "selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb
index db36f07c..db6943ab 100644
--- a/lib/l10n/arb/app_zh_CN.arb
+++ b/lib/l10n/arb/app_zh_CN.arb
@@ -1,5 +1,5 @@
{
- "@@locale": "zh-CN",
+ "@@locale": "zh_CN",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"@appName": {
diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb
index 940c7e28..cf4f7b4a 100644
--- a/lib/l10n/arb/app_zh_TW.arb
+++ b/lib/l10n/arb/app_zh_TW.arb
@@ -1,5 +1,5 @@
{
- "@@locale": "zh-TW",
+ "@@locale": "zh_TW",
"@@last_modified": "2026-01-16",
"appName": "SpotiFLAC",
"@appName": {
diff --git a/lib/main.dart b/lib/main.dart
index 6963a9ee..89d5d93c 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,16 +1,20 @@
+import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/app.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
+import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
+import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -88,15 +92,142 @@ class _EagerInitialization extends ConsumerStatefulWidget {
_EagerInitializationState();
}
-class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
+class _EagerInitializationState extends ConsumerState<_EagerInitialization>
+ with WidgetsBindingObserver {
+ ProviderSubscription? _localLibraryEnabledSub;
+ Timer? _downloadHistoryWarmupTimer;
+ Timer? _libraryCollectionsWarmupTimer;
+ Timer? _localLibraryWarmupTimer;
+ bool _localLibraryWarmupScheduled = false;
+ bool _autoScanTriggeredOnLaunch = false;
+
@override
void initState() {
super.initState();
- _initializeAppServices();
- _initializeExtensions();
- ref.read(downloadHistoryProvider);
- ref.read(localLibraryProvider);
- ref.read(libraryCollectionsProvider);
+ WidgetsBinding.instance.addObserver(this);
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ _initializeAppServices();
+ _initializeExtensions();
+ _initializeDeferredProviders();
+ });
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ _localLibraryEnabledSub?.close();
+ _downloadHistoryWarmupTimer?.cancel();
+ _libraryCollectionsWarmupTimer?.cancel();
+ _localLibraryWarmupTimer?.cancel();
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed) {
+ _maybeAutoScanLocalLibrary();
+ }
+ }
+
+ void _initializeDeferredProviders() {
+ _downloadHistoryWarmupTimer = _scheduleProviderWarmup(
+ const Duration(milliseconds: 400),
+ () => ref.read(downloadHistoryProvider),
+ );
+ _libraryCollectionsWarmupTimer = _scheduleProviderWarmup(
+ const Duration(milliseconds: 900),
+ () => ref.read(libraryCollectionsProvider),
+ );
+
+ _maybeScheduleLocalLibraryWarmup(
+ ref.read(
+ settingsProvider.select((settings) => settings.localLibraryEnabled),
+ ),
+ );
+
+ _localLibraryEnabledSub = ref.listenManual(
+ settingsProvider.select((settings) => settings.localLibraryEnabled),
+ (previous, next) {
+ if (next == true) {
+ _maybeScheduleLocalLibraryWarmup(true);
+ }
+ },
+ );
+ }
+
+ Timer _scheduleProviderWarmup(Duration delay, VoidCallback action) {
+ return Timer(delay, () {
+ if (!mounted) return;
+ action();
+ });
+ }
+
+ void _maybeScheduleLocalLibraryWarmup(bool enabled) {
+ if (!enabled || _localLibraryWarmupScheduled) return;
+ _localLibraryWarmupScheduled = true;
+ _localLibraryWarmupTimer = _scheduleProviderWarmup(
+ const Duration(milliseconds: 1600),
+ () {
+ ref.read(localLibraryProvider);
+ // Trigger auto-scan after initial warmup on first app launch.
+ if (!_autoScanTriggeredOnLaunch) {
+ _autoScanTriggeredOnLaunch = true;
+ // Give the provider a moment to load existing data before scanning.
+ Future.delayed(const Duration(milliseconds: 500), () {
+ if (mounted) _maybeAutoScanLocalLibrary();
+ });
+ }
+ },
+ );
+ }
+
+ /// Checks whether an automatic incremental scan should be triggered based on
+ /// the user's auto-scan preference and the time since the last scan.
+ Future _maybeAutoScanLocalLibrary() async {
+ if (!mounted) return;
+
+ final settings = ref.read(settingsProvider);
+ if (!settings.localLibraryEnabled) return;
+ 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);
+
+ if (lastScanned != null) {
+ final elapsed = now.difference(lastScanned);
+
+ switch (settings.localLibraryAutoScan) {
+ case 'on_open':
+ // Cooldown of 10 minutes to prevent rapid re-scans.
+ if (elapsed.inMinutes < 10) return;
+ break;
+ case 'daily':
+ if (elapsed.inHours < 24) return;
+ break;
+ case 'weekly':
+ if (elapsed.inDays < 7) return;
+ break;
+ default:
+ return;
+ }
+ }
+
+ // All checks passed -- start an incremental scan.
+ final iosBookmark = settings.localLibraryBookmark;
+ ref
+ .read(localLibraryProvider.notifier)
+ .startScan(
+ settings.localLibraryPath,
+ iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
+ );
}
Future _initializeAppServices() async {
diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart
index 4d8a650e..db55029b 100644
--- a/lib/models/download_item.dart
+++ b/lib/models/download_item.dart
@@ -34,6 +34,7 @@ class DownloadItem {
final DownloadErrorType? errorType;
final DateTime createdAt;
final String? qualityOverride; // Override quality for this specific download
+ final String? playlistName; // Playlist context for folder organization
const DownloadItem({
required this.id,
@@ -48,6 +49,7 @@ class DownloadItem {
this.errorType,
required this.createdAt,
this.qualityOverride,
+ this.playlistName,
});
DownloadItem copyWith({
@@ -63,6 +65,7 @@ class DownloadItem {
DownloadErrorType? errorType,
DateTime? createdAt,
String? qualityOverride,
+ String? playlistName,
}) {
return DownloadItem(
id: id ?? this.id,
@@ -77,6 +80,7 @@ class DownloadItem {
errorType: errorType ?? this.errorType,
createdAt: createdAt ?? this.createdAt,
qualityOverride: qualityOverride ?? this.qualityOverride,
+ playlistName: playlistName ?? this.playlistName,
);
}
diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart
index 098290ce..961e6d6d 100644
--- a/lib/models/download_item.g.dart
+++ b/lib/models/download_item.g.dart
@@ -21,6 +21,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem(
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
createdAt: DateTime.parse(json['createdAt'] as String),
qualityOverride: json['qualityOverride'] as String?,
+ playlistName: json['playlistName'] as String?,
);
Map _$DownloadItemToJson(DownloadItem instance) =>
@@ -37,6 +38,7 @@ Map _$DownloadItemToJson(DownloadItem instance) =>
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
'createdAt': instance.createdAt.toIso8601String(),
'qualityOverride': instance.qualityOverride,
+ 'playlistName': instance.playlistName,
};
const _$DownloadStatusEnumMap = {
diff --git a/lib/models/settings.dart b/lib/models/settings.dart
index 8501a4e3..c2f2eed6 100644
--- a/lib/models/settings.dart
+++ b/lib/models/settings.dart
@@ -20,6 +20,7 @@ class AppSettings {
final String updateChannel;
final bool hasSearchedBefore;
final String folderOrganization;
+ final bool createPlaylistFolder;
final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist;
@@ -33,6 +34,7 @@ class AppSettings {
final bool enableLogging;
final bool useExtensionProviders;
final String? searchProvider;
+ final String? homeFeedProvider;
final bool separateSingles;
final String albumFolderStructure;
final bool showExtensionStore;
@@ -41,7 +43,7 @@ class AppSettings {
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final int
- youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
+ youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
final int
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
final bool
@@ -61,6 +63,8 @@ class AppSettings {
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
+ final String
+ localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
@@ -96,6 +100,7 @@ class AppSettings {
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
+ this.createPlaylistFolder = false,
this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false,
@@ -104,11 +109,12 @@ class AppSettings {
this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
- this.useCustomSpotifyCredentials = true,
+ this.useCustomSpotifyCredentials = false,
this.metadataSource = 'deezer',
this.enableLogging = false,
this.useExtensionProviders = true,
this.searchProvider,
+ this.homeFeedProvider,
this.separateSingles = false,
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
@@ -126,6 +132,7 @@ class AppSettings {
this.localLibraryPath = '',
this.localLibraryBookmark = '',
this.localLibraryShowDuplicates = true,
+ this.localLibraryAutoScan = 'off',
this.hasCompletedTutorial = false,
this.lyricsProviders = const [
'lrclib',
@@ -149,7 +156,7 @@ class AppSettings {
String? downloadDirectory,
String? storageMode,
String? downloadTreeUri,
- bool? autoFallback,
+ bool? autoFallback,
bool? embedMetadata,
bool? embedLyrics,
bool? maxQualityCover,
@@ -159,6 +166,7 @@ class AppSettings {
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
+ bool? createPlaylistFolder,
bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist,
@@ -173,6 +181,8 @@ class AppSettings {
bool? useExtensionProviders,
String? searchProvider,
bool clearSearchProvider = false,
+ String? homeFeedProvider,
+ bool clearHomeFeedProvider = false,
bool? separateSingles,
String? albumFolderStructure,
bool? showExtensionStore,
@@ -190,6 +200,7 @@ class AppSettings {
String? localLibraryPath,
String? localLibraryBookmark,
bool? localLibraryShowDuplicates,
+ String? localLibraryAutoScan,
bool? hasCompletedTutorial,
List? lyricsProviders,
bool? lyricsIncludeTranslationNetease,
@@ -215,6 +226,7 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
+ createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
@@ -236,6 +248,9 @@ class AppSettings {
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
+ homeFeedProvider: clearHomeFeedProvider
+ ? null
+ : (homeFeedProvider ?? this.homeFeedProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
@@ -256,6 +271,7 @@ class AppSettings {
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
+ localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan,
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease:
diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart
index cc47ca8e..914e224f 100644
--- a/lib/models/settings.g.dart
+++ b/lib/models/settings.g.dart
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
+ createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false,
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist:
@@ -33,11 +34,12 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings(
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
- json['useCustomSpotifyCredentials'] as bool? ?? true,
+ json['useCustomSpotifyCredentials'] as bool? ?? false,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?,
+ homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
@@ -58,6 +60,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings(
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
+ localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
lyricsProviders:
(json['lyricsProviders'] as List?)
@@ -100,6 +103,7 @@ Map _$AppSettingsToJson(
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
+ 'createPlaylistFolder': instance.createPlaylistFolder,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'filterContributingArtistsInAlbumArtist':
@@ -114,6 +118,7 @@ Map _$AppSettingsToJson(
'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider,
+ 'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
@@ -131,6 +136,7 @@ Map _$AppSettingsToJson(
'localLibraryPath': instance.localLibraryPath,
'localLibraryBookmark': instance.localLibraryBookmark,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
+ 'localLibraryAutoScan': instance.localLibraryAutoScan,
'hasCompletedTutorial': instance.hasCompletedTutorial,
'lyricsProviders': instance.lyricsProviders,
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
diff --git a/lib/models/track.dart b/lib/models/track.dart
index 244a7a65..e4d850c2 100644
--- a/lib/models/track.dart
+++ b/lib/models/track.dart
@@ -21,6 +21,7 @@ class Track {
final ServiceAvailability? availability;
final String? source;
final String? albumType;
+ final int? totalTracks;
final String? itemType;
const Track({
@@ -41,10 +42,21 @@ class Track {
this.availability,
this.source,
this.albumType,
+ this.totalTracks,
this.itemType,
});
- bool get isSingle => albumType == 'single' || albumType == 'ep';
+ bool get isSingle {
+ switch (albumType?.toLowerCase()) {
+ case 'single':
+ return true;
+ case 'ep':
+ final count = totalTracks;
+ return count == null || count <= 1;
+ default:
+ return false;
+ }
+ }
bool get isAlbumItem => itemType == 'album';
diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart
index f640cfe7..5e361ab6 100644
--- a/lib/models/track.g.dart
+++ b/lib/models/track.g.dart
@@ -28,6 +28,7 @@ Track _$TrackFromJson(Map json) => Track(
),
source: json['source'] as String?,
albumType: json['albumType'] as String?,
+ totalTracks: (json['totalTracks'] as num?)?.toInt(),
itemType: json['itemType'] as String?,
);
@@ -49,6 +50,7 @@ Map _$TrackToJson(Track instance) => {
'availability': instance.availability,
'source': instance.source,
'albumType': instance.albumType,
+ 'totalTracks': instance.totalTracks,
'itemType': instance.itemType,
};
diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart
index 1e69a2fa..a457b3d2 100644
--- a/lib/providers/download_queue_provider.dart
+++ b/lib/providers/download_queue_provider.dart
@@ -122,7 +122,7 @@ class DownloadHistoryItem {
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: normalizeOptionalString(json['albumArtist'] as String?),
- coverUrl: json['coverUrl'] as String?,
+ coverUrl: normalizeCoverReference(json['coverUrl']?.toString()),
filePath: json['filePath'] as String,
storageMode: json['storageMode'] as String?,
downloadTreeUri: json['downloadTreeUri'] as String?,
@@ -176,7 +176,7 @@ class DownloadHistoryItem {
artistName: artistName ?? this.artistName,
albumName: albumName ?? this.albumName,
albumArtist: albumArtist ?? this.albumArtist,
- coverUrl: coverUrl ?? this.coverUrl,
+ coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl),
filePath: filePath ?? this.filePath,
storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
@@ -205,6 +205,7 @@ class DownloadHistoryState {
final List items;
final Map _bySpotifyId;
final Map _byIsrc;
+ final Map _byTrackArtistKey;
DownloadHistoryState({this.items = const []})
: _bySpotifyId = Map.fromEntries(
@@ -218,8 +219,25 @@ class DownloadHistoryState {
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
+ ),
+ _byTrackArtistKey = Map.fromEntries(
+ items
+ .map(
+ (item) => MapEntry(
+ _trackArtistKey(item.trackName, item.artistName),
+ item,
+ ),
+ )
+ .where((entry) => entry.key.isNotEmpty),
);
+ static String _trackArtistKey(String trackName, String artistName) {
+ final normalizedTrack = trackName.trim().toLowerCase();
+ if (normalizedTrack.isEmpty) return '';
+ final normalizedArtist = artistName.trim().toLowerCase();
+ return '$normalizedTrack|$normalizedArtist';
+ }
+
bool isDownloaded(String spotifyId) => _bySpotifyId.containsKey(spotifyId);
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
@@ -231,16 +249,9 @@ class DownloadHistoryState {
String trackName,
String artistName,
) {
- final normalizedTrack = trackName.trim().toLowerCase();
- final normalizedArtist = artistName.trim().toLowerCase();
- if (normalizedTrack.isEmpty) return null;
- for (final item in items) {
- if (item.trackName.trim().toLowerCase() == normalizedTrack &&
- item.artistName.trim().toLowerCase() == normalizedArtist) {
- return item;
- }
- }
- return null;
+ final key = _trackArtistKey(trackName, artistName);
+ if (key.isEmpty) return null;
+ return _byTrackArtistKey[key];
}
DownloadHistoryState copyWith({List? items}) {
@@ -252,10 +263,12 @@ class DownloadHistoryNotifier extends Notifier {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
static const int _audioMetadataBackfillMaxPerLaunch = 24;
+ static const _startupMaintenanceDelay = Duration(seconds: 2);
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
bool _isSafRepairInProgress = false;
bool _isAudioMetadataBackfillInProgress = false;
+ bool _startupMaintenanceScheduled = false;
@override
DownloadHistoryState build() {
@@ -292,33 +305,45 @@ class DownloadHistoryNotifier extends Notifier {
state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from SQLite database');
-
- if (Platform.isAndroid) {
- Future.microtask(() async {
- await _repairMissingSafEntries(
- items,
- maxItems: _safRepairMaxPerLaunch,
- );
- await cleanupOrphanedDownloads();
- await _backfillAudioMetadata(
- state.items,
- maxItems: _audioMetadataBackfillMaxPerLaunch,
- );
- });
- } else {
- Future.microtask(() async {
- await cleanupOrphanedDownloads();
- await _backfillAudioMetadata(
- state.items,
- maxItems: _audioMetadataBackfillMaxPerLaunch,
- );
- });
- }
+ _scheduleStartupMaintenance(items);
} catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack);
}
}
+ void _scheduleStartupMaintenance(List initialItems) {
+ if (_startupMaintenanceScheduled) {
+ return;
+ }
+ _startupMaintenanceScheduled = true;
+
+ unawaited(
+ Future.delayed(_startupMaintenanceDelay, () async {
+ try {
+ if (Platform.isAndroid) {
+ await _repairMissingSafEntries(
+ initialItems,
+ maxItems: _safRepairMaxPerLaunch,
+ );
+ }
+
+ await cleanupOrphanedDownloads();
+
+ final currentItems = state.items;
+ if (currentItems.isNotEmpty) {
+ await _backfillAudioMetadata(
+ currentItems,
+ maxItems: _audioMetadataBackfillMaxPerLaunch,
+ );
+ }
+ } catch (e, stack) {
+ _historyLog.w('Startup history maintenance failed: $e');
+ _historyLog.d('$stack');
+ }
+ }),
+ );
+ }
+
String _fileNameFromUri(String uri) {
try {
final parsed = Uri.parse(uri);
@@ -745,6 +770,37 @@ class DownloadHistoryNotifier extends Notifier {
/// Remove history entries where the file no longer exists on disk
/// Returns the number of orphaned entries removed
+ /// Audio file extensions that the app commonly produces or converts between.
+ static const _audioExtensions = [
+ '.flac',
+ '.m4a',
+ '.mp3',
+ '.opus',
+ '.ogg',
+ '.wav',
+ '.aac',
+ ];
+
+ /// When the original file is missing, check whether a sibling with a
+ /// different audio extension exists (e.g. the user converted .flac → .opus).
+ /// Returns the path of the first match found, or `null` if none exist.
+ Future _findConvertedSibling(String originalPath) async {
+ // Strip the current extension to get the base path.
+ final dotIndex = originalPath.lastIndexOf('.');
+ if (dotIndex < 0) return null;
+ final basePath = originalPath.substring(0, dotIndex);
+ final originalExt = originalPath.substring(dotIndex).toLowerCase();
+
+ for (final ext in _audioExtensions) {
+ if (ext == originalExt) continue;
+ final candidatePath = '$basePath$ext';
+ try {
+ if (await fileExists(candidatePath)) return candidatePath;
+ } catch (_) {}
+ }
+ return null;
+ }
+
Future cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...');
@@ -766,7 +822,21 @@ class DownloadHistoryNotifier extends Notifier {
if (filePath == null || filePath.isEmpty) return null;
pathById[id] = filePath;
try {
- return MapEntry(id, await fileExists(filePath));
+ if (await fileExists(filePath)) return MapEntry(id, true);
+
+ // Original file missing -- check for a converted sibling.
+ final sibling = await _findConvertedSibling(filePath);
+ if (sibling != null) {
+ _historyLog.i(
+ 'Found converted sibling for $id: $filePath → $sibling',
+ );
+ // Update the stored path so future checks succeed immediately.
+ await _db.updateFilePath(id, sibling);
+ pathById[id] = sibling;
+ return MapEntry(id, true);
+ }
+
+ return MapEntry(id, false);
} catch (e) {
_historyLog.w('Error checking file existence for $id: $e');
return MapEntry(id, false);
@@ -904,11 +974,12 @@ class DownloadQueueNotifier extends Notifier {
StreamSubscription