Compare commits

..

2 Commits

Author SHA1 Message Date
zarzet 6ac4f555f6 fix(ios): remove stale built-in Spotify bridge handlers 2026-03-11 17:08:46 +07:00
zarzet 098544393e docs: add centered Trendshift badge below README banner 2026-03-11 17:08:45 +07:00
74 changed files with 1308 additions and 10919 deletions
+2 -14
View File
@@ -344,18 +344,9 @@ jobs:
VERSION=${{ needs.get-version.outputs.version }}
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
# Start with git-cliff changelog, but replace its compare footer with a
# deterministic previous-tag lookup from git.
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
if [ -n "$PREVIOUS_TAG" ]; then
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
>> /tmp/release_body.txt
fi
# Start with git-cliff changelog
cp /tmp/changelog.txt /tmp/release_body.txt
# Append download section
cat >> /tmp/release_body.txt << FOOTER
@@ -433,10 +424,7 @@ jobs:
else
# 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/&/\&amp;/g' | \
-1
View File
@@ -67,7 +67,6 @@ AGENTS.md
# Temp/misc
nul
network_requests.txt
# Log files
*.log
+2 -2
View File
@@ -25,7 +25,7 @@
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
@@ -46,7 +46,7 @@ Extensions allow the community to add new music sources and features without wai
5. Set provider priority in **Settings > Extensions > Provider Priority**
### Developing Extensions
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
## Other project
@@ -115,8 +115,10 @@ 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()
}
@@ -38,7 +38,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 = 1200L
private val STREAM_POLLING_INTERVAL_MS = 800L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
@@ -469,32 +469,6 @@ 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()) {
@@ -729,80 +703,6 @@ 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
@@ -973,66 +873,6 @@ class MainActivity: FlutterFragmentActivity() {
return null
}
private val cueSiblingAudioExtensions = listOf(
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
)
private fun getSafChildFileLookup(
dir: DocumentFile,
cache: MutableMap<String, Map<String, DocumentFile>>,
): Map<String, DocumentFile> {
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<String, Map<String, DocumentFile>>,
): 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 "[]"
@@ -1051,7 +891,6 @@ class MainActivity: FlutterFragmentActivity() {
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
var traversalErrors = 0
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
@@ -1148,6 +987,7 @@ class MainActivity: FlutterFragmentActivity() {
var tempCuePath: String? = null
var tempAudioPath: String? = null
try {
// Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) {
errors++
@@ -1156,14 +996,27 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath)
val audioDoc = resolveCueAudioSibling(
parentDir = parentDir,
cueName = cueName,
audioFileName = audioFileName,
childLookupCache = safChildLookupCache,
)
// Find the referenced audio file as a sibling in the same SAF directory
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common audio extensions with the CUE base name
if (audioDoc == null) {
val cueBaseName = cueName.substringBeforeLast('.')
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
// Try uppercase
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
if (audioDoc != null) break
}
}
if (audioDoc == null) {
android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
@@ -1199,6 +1052,7 @@ class MainActivity: FlutterFragmentActivity() {
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
// Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath,
tempDir,
@@ -1257,17 +1111,35 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
if (metadataObj == 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) {
errors++
} else {
try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
metadataObj.put("filePath", doc.uri.toString())
metadataObj.put("fileModTime", lastModified)
results.put(metadataObj)
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++
}
} catch (_: Exception) {
errors++
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
}
@@ -1342,7 +1214,6 @@ class MainActivity: FlutterFragmentActivity() {
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
var traversalErrors = 0
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
@@ -1527,12 +1398,22 @@ class MainActivity: FlutterFragmentActivity() {
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,
)
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common audio extensions with the CUE base name
if (audioDoc == null) {
val cueBaseName = cueName.substringBeforeLast('.')
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
}
}
if (audioDoc == null) {
android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
@@ -1620,13 +1501,24 @@ class MainActivity: FlutterFragmentActivity() {
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,
)
var audioDoc: DocumentFile? = null
if (!audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common extensions with CUE base name
if (audioDoc == null) {
val cueName = try { cueDoc.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
}
}
}
if (audioDoc != null) {
cueReferencedAudioUris.add(audioDoc.uri.toString())
}
@@ -1674,18 +1566,36 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
if (metadataObj == 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) {
errors++
} else {
try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
metadataObj.put("filePath", doc.uri.toString())
metadataObj.put("fileModTime", safeLastModified)
metadataObj.put("lastModified", safeLastModified)
results.put(metadataObj)
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++
}
} catch (_: Exception) {
errors++
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
}
@@ -2646,22 +2556,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getQobuzMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getQobuzMetadata(resourceType, resourceId)
}
result.success(response)
}
"getTidalMetadata" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val resourceId = call.argument<String>("resource_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getTidalMetadata(resourceType, resourceId)
}
result.success(response)
}
"parseDeezerUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2669,13 +2563,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"parseQobuzUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseQobuzURLExport(url)
}
result.success(response)
}
"parseTidalUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -2904,15 +2791,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"searchTracksWithMetadataProviders" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20
val includeExtensions = call.argument<Boolean>("include_extensions") ?: true
val response = withContext(Dispatchers.IO) {
Gobackend.searchTracksWithMetadataProvidersJSON(query, limit.toLong(), includeExtensions)
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
@@ -3118,25 +2996,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"setStoreRegistryUrl" -> {
val registryUrl = call.argument<String>("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<Boolean>("force_refresh") ?: false
val response = withContext(Dispatchers.IO) {
@@ -3212,18 +3071,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"scanLibraryFolderIncrementalFromSnapshot" -> {
val folderPath = call.argument<String>("folder_path") ?: ""
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
val response = withContext(Dispatchers.IO) {
safScanActive = false
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
folderPath,
snapshotPath,
)
}
result.success(response)
}
"scanSafTree" -> {
val treeUri = call.argument<String>("tree_uri") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3239,16 +3086,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"scanSafTreeIncrementalFromSnapshot" -> {
val treeUri = call.argument<String>("tree_uri") ?: ""
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
val response = withContext(Dispatchers.IO) {
val existingFilesJson =
loadExistingFilesJsonFromSnapshot(snapshotPath)
scanSafTreeIncremental(treeUri, existingFilesJson)
}
result.success(response)
}
"getSafFileModTimes" -> {
val uris = call.argument<String>("uris") ?: "[]"
val response = withContext(Dispatchers.IO) {
@@ -3279,10 +3116,13 @@ class MainActivity: FlutterFragmentActivity() {
try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val metadata = readAudioMetadataFromUri(uri)
?: return@withContext """{"error":"Failed to read SAF audio metadata"}"""
metadata.put("filePath", filePath)
metadata.toString()
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) {}
}
} else {
Gobackend.readAudioMetadataJSON(filePath)
}
+3 -1
View File
@@ -22,7 +22,7 @@ body = """
{% 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 %}
{%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
{%- endfor %}
{% endfor %}
@@ -58,6 +58,8 @@ split_commits = false
# Regex for preprocessing the commit messages
commit_preprocessors = [
# Remove PR number from message (we add it back via GitHub integration)
{ pattern = '\(#(\d+)\)', replace = '' },
# Strip conventional commit prefix for cleaner messages
# (group header already shows the type)
]
+1 -12
View File
@@ -1566,14 +1566,7 @@ 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":
@@ -1602,10 +1595,6 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
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())
@@ -1622,7 +1611,7 @@ func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (strin
return pngPath, nil
}
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
imageData, mimeType, err := extractAnyCoverArt(filePath)
if err != nil {
return "", err
}
+1
View File
@@ -104,6 +104,7 @@ 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")
+14 -27
View File
@@ -114,6 +114,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// PERFORMER
if strings.HasPrefix(upper, "PERFORMER ") {
value := unquoteCue(line[len("PERFORMER "):])
if currentTrack != nil {
@@ -124,6 +125,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// TITLE
if strings.HasPrefix(upper, "TITLE ") {
value := unquoteCue(line[len("TITLE "):])
if currentTrack != nil {
@@ -134,6 +136,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// FILE
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
// Extract filename and type
@@ -145,6 +148,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// TRACK
if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil {
@@ -164,6 +168,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// INDEX
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
parts := strings.Fields(line)
if len(parts) >= 3 {
@@ -179,6 +184,7 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// ISRC
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
continue
@@ -424,15 +430,7 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
// 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)
return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime)
}
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
@@ -443,35 +441,23 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
// - 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) {
return scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
}
func scanCueFileForLibraryInternal(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)
}
// Resolve audio file — optionally in an overridden directory
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)
return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
}
// Try to get quality info from the audio file
@@ -554,6 +540,7 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
duration = int(totalDurationSec - track.StartTime)
}
// Use a unique ID based on pathBase + track number
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
// Use a virtual file path that includes the track number to ensure
+5 -11
View File
@@ -394,6 +394,11 @@ 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,
@@ -456,17 +461,6 @@ 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) {
+1
View File
@@ -34,6 +34,7 @@ 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)
+66 -224
View File
@@ -1156,66 +1156,6 @@ 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 {
@@ -1235,25 +1175,6 @@ 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 {
@@ -1390,6 +1311,7 @@ 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)
}
@@ -1753,54 +1675,79 @@ 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 using the
// configured metadata-provider priority.
// When search_online is true, search for metadata from internet.
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc)
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()
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 {
req.SpotifyID = track.ID
{
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)
}
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
}
// 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)
}
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
@@ -2202,21 +2149,6 @@ 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 {
@@ -2602,28 +2534,6 @@ 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 {
@@ -2873,27 +2783,6 @@ 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
}
@@ -3041,45 +2930,6 @@ 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 {
@@ -3237,10 +3087,6 @@ 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()
}
@@ -3252,7 +3098,3 @@ func CancelLibraryScanJSON() {
func ReadAudioMetadataJSON(filePath string) (string, error) {
return ReadAudioMetadata(filePath)
}
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
return ReadAudioMetadataWithDisplayName(filePath, displayName)
}
+2
View File
@@ -151,6 +151,7 @@ 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)
@@ -428,6 +429,7 @@ 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 {
+6 -315
View File
@@ -70,7 +70,6 @@ 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"`
}
@@ -328,12 +327,6 @@ 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
}
@@ -491,7 +484,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
return &urlResult, nil
}
const ExtDownloadTimeout = DownloadTimeout
const ExtDownloadTimeout = 5 * time.Minute
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
if !p.extension.Manifest.IsDownloadProvider() {
@@ -607,30 +600,8 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
return nil, nil
}
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 {
for _, provider := range providers {
result, err := provider.SearchTracks(query, limit)
if err != nil {
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
@@ -650,8 +621,6 @@ var providerPriorityMu sync.RWMutex
var metadataProviderPriority []string
var metadataProviderPriorityMu sync.RWMutex
var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
func SetProviderPriority(providerIDs []string) {
providerPriorityMu.Lock()
defer providerPriorityMu.Unlock()
@@ -676,7 +645,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
sanitized := make([]string, 0, len(providerIDs)+3)
sanitized := make([]string, 0, len(providerIDs)+1)
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
@@ -689,12 +658,8 @@ func SetMetadataProviderPriority(providerIDs []string) {
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)
if _, exists := seen["deezer"]; !exists {
sanitized = append([]string{"deezer"}, sanitized...)
}
metadataProviderPriority = sanitized
@@ -706,7 +671,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
return []string{"deezer", "qobuz", "tidal"}
return []string{"deezer"}
}
result := make([]string, len(metadataProviderPriority))
@@ -723,165 +688,6 @@ 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()
@@ -977,24 +783,6 @@ 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
@@ -1015,73 +803,6 @@ 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 for genre/label if we have ISRC
if req.ISRC != "" && (req.Genre == "" || req.Label == "") {
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
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label)
}
}
}
if req.Source != "" &&
!isBuiltInProvider(strings.ToLower(req.Source)) &&
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
@@ -1175,30 +896,6 @@ 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
}
@@ -1752,12 +1449,6 @@ 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
}
-68
View File
@@ -1,68 +0,0 @@
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)
}
}
+6 -114
View File
@@ -8,7 +8,6 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
@@ -130,8 +129,9 @@ var (
)
const (
cacheTTL = 30 * time.Minute
cacheFileName = "store_cache.json"
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.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: "", // No default - user must provide a registry URL
registryURL: defaultRegistryURL,
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
@@ -149,36 +149,6 @@ 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()
@@ -236,11 +206,6 @@ 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
@@ -371,81 +336,6 @@ 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/<branch>/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/<owner>/<repo>[/...]
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>/<repo>")
}
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)
@@ -484,10 +374,12 @@ 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) &&
+3 -3
View File
@@ -31,7 +31,7 @@ func getRandomUserAgent() string {
const (
DefaultTimeout = 60 * time.Second
DownloadTimeout = 24 * time.Hour
DownloadTimeout = 120 * time.Second
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
@@ -350,7 +350,7 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
func getRetryAfterDuration(resp *http.Response) time.Duration {
retryAfter := resp.Header.Get("Retry-After")
if retryAfter == "" {
return 60 * time.Second
return 60 * time.Second // Default wait time
}
if seconds, err := strconv.Atoi(retryAfter); err == nil {
@@ -364,7 +364,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
}
}
return 60 * time.Second
return 60 * time.Second // Default
}
func ReadResponseBody(resp *http.Response) ([]byte, error) {
+65 -172
View File
@@ -1,12 +1,10 @@
package gobackend
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
@@ -73,11 +71,6 @@ type libraryAudioFileInfo struct {
modTime int64
}
type scannedCueFileInfo struct {
sheet *CueSheet
audioPath string
}
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
var files []libraryAudioFileInfo
@@ -151,7 +144,12 @@ func ScanLibraryFolder(folderPath string) (string, error) {
return "[]", err
}
totalFiles := len(audioFileInfos)
audioFiles := make([]string, 0, len(audioFileInfos))
for _, fileInfo := range audioFileInfos {
audioFiles = append(audioFiles, fileInfo.path)
}
totalFiles := len(audioFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
@@ -171,29 +169,22 @@ func ScanLibraryFolder(folderPath string) (string, error) {
// 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
for _, filePath := range audioFiles {
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
for i, filePath := range audioFiles {
select {
case <-cancelCh:
return "[]", fmt.Errorf("scan cancelled")
@@ -210,20 +201,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
// 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)
}
cueResults, err := ScanCueFileForLibrary(filePath, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
@@ -241,7 +219,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
continue
}
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
result, err := scanAudioFile(filePath, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
@@ -267,15 +245,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
}
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
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)
ext := strings.ToLower(filepath.Ext(filePath))
result := &LibraryScanResult{
ID: generateLibraryID(filePath),
@@ -284,9 +254,7 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
Format: strings.TrimPrefix(ext, "."),
}
if knownModTime > 0 {
result.FileModTime = knownModTime
} else if info, err := os.Stat(filePath); err == nil {
if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
@@ -294,7 +262,7 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" && ext != ".m4a" {
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
@@ -308,31 +276,15 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
case ".mp3":
return scanMP3File(filePath, result)
case ".opus", ".ogg":
return scanOggFile(filePath, result, displayNameHint)
return scanOggFile(filePath, result)
default:
return scanFromFilename(filePath, displayNameHint, result)
return scanFromFilename(filePath, result)
}
}
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)
func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
if result.TrackName == "" {
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
}
if result.ArtistName == "" {
result.ArtistName = "Unknown Artist"
@@ -345,7 +297,7 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
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
@@ -367,7 +319,7 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
}
}
applyDefaultLibraryMetadata(filePath, "", result)
applyDefaultLibraryMetadata(filePath, result)
return result, nil
}
@@ -379,14 +331,14 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
result.SampleRate = quality.SampleRate
}
return scanFromFilename(filePath, "", result)
return scanFromFilename(filePath, result)
}
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
@@ -413,16 +365,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
}
}
applyDefaultLibraryMetadata(filePath, "", result)
applyDefaultLibraryMetadata(filePath, result)
return result, nil
}
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadOggVorbisComments(filePath)
if err != nil {
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
return scanFromFilename(filePath, result)
}
result.TrackName = metadata.Title
@@ -445,14 +397,13 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
}
}
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
applyDefaultLibraryMetadata(filePath, result)
return result, nil
}
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
parts := strings.SplitN(filename, " - ", 2)
if len(parts) == 2 {
@@ -475,7 +426,7 @@ func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResul
dir := filepath.Dir(filePath)
result.AlbumName = filepath.Base(dir)
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
if result.AlbumName == "." || result.AlbumName == "" {
result.AlbumName = "Unknown Album"
}
@@ -522,12 +473,8 @@ 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 := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
result, err := scanAudioFile(filePath, scanTime)
if err != nil {
return "", err
}
@@ -543,43 +490,7 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
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) {
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
@@ -592,6 +503,13 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
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()
@@ -623,13 +541,14 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
// 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
}
// Build a set of existing CUE virtual path base files for incremental matching.
// CUE tracks are stored with virtual paths like "/path/album.cue#track01".
// We need to match these against the actual .cue file's modTime.
cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk
for _, f := range currentFiles {
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
cueBaseModTimes[f.path] = f.modTime
}
}
@@ -638,12 +557,25 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
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 {
hasCueTracks := false
for existingPath := range existingFiles {
if strings.HasPrefix(existingPath, f.path+"#track") {
hasCueTracks = true
break
}
}
if hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
if f.modTime == cueTrackModTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
// Use modTime from any virtual path (they all share the same .cue modTime)
for existingPath, modTime := range existingFiles {
if strings.HasPrefix(existingPath, f.path+"#track") {
if f.modTime == modTime {
skippedCount++
} else {
filesToScan = append(filesToScan, f)
}
break
}
}
continue
}
@@ -698,7 +630,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
// 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" {
@@ -706,10 +637,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
if err == nil && sheet.FileName != "" {
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
if audioPath != "" {
parsedCueFiles[f.path] = scannedCueFileInfo{
sheet: sheet,
audioPath: audioPath,
}
cueReferencedAudioFilesInc[audioPath] = true
}
}
@@ -733,20 +660,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
// 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)
}
cueResults, err := ScanCueFileForLibrary(f.path, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
@@ -761,7 +675,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
continue
}
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
result, err := scanAudioFile(f.path, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
@@ -795,24 +709,3 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
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)
}
+4 -31
View File
@@ -34,16 +34,10 @@ var (
downloadDir string
downloadDirMu sync.RWMutex
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex
multiProgressDirty = true
cachedMultiProgress = "{\"items\":{}}"
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
multiMu sync.RWMutex
)
func markMultiProgressDirtyLocked() {
multiProgressDirty = true
}
func getProgress() DownloadProgress {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -64,25 +58,13 @@ func getProgress() DownloadProgress {
func GetMultiProgress() string {
multiMu.RLock()
if !multiProgressDirty {
cached := cachedMultiProgress
multiMu.RUnlock()
return cached
}
multiMu.RUnlock()
defer multiMu.RUnlock()
multiMu.Lock()
defer multiMu.Unlock()
if !multiProgressDirty {
return cachedMultiProgress
}
jsonBytes, err := json.Marshal(multiProgress)
if err != nil {
return "{\"items\":{}}"
}
cachedMultiProgress = string(jsonBytes)
multiProgressDirty = false
return cachedMultiProgress
return string(jsonBytes)
}
func GetItemProgress(itemID string) string {
@@ -108,7 +90,6 @@ func StartItemProgress(itemID string) {
IsDownloading: true,
Status: "downloading",
}
markMultiProgressDirtyLocked()
}
func SetItemBytesTotal(itemID string, total int64) {
@@ -117,7 +98,6 @@ func SetItemBytesTotal(itemID string, total int64) {
if item, ok := multiProgress.Items[itemID]; ok {
item.BytesTotal = total
markMultiProgressDirtyLocked()
}
}
@@ -130,7 +110,6 @@ func SetItemBytesReceived(itemID string, received int64) {
if item.BytesTotal > 0 {
item.Progress = float64(received) / float64(item.BytesTotal)
}
markMultiProgressDirtyLocked()
}
}
@@ -144,7 +123,6 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
if item.BytesTotal > 0 {
item.Progress = float64(received) / float64(item.BytesTotal)
}
markMultiProgressDirtyLocked()
}
}
@@ -156,7 +134,6 @@ func CompleteItemProgress(itemID string) {
item.Progress = 1.0
item.IsDownloading = false
item.Status = "completed"
markMultiProgressDirtyLocked()
}
}
@@ -172,7 +149,6 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
if bytesTotal > 0 {
item.BytesTotal = bytesTotal
}
markMultiProgressDirtyLocked()
}
}
@@ -183,7 +159,6 @@ func SetItemFinalizing(itemID string) {
if item, ok := multiProgress.Items[itemID]; ok {
item.Progress = 1.0
item.Status = "finalizing"
markMultiProgressDirtyLocked()
}
}
@@ -192,7 +167,6 @@ func RemoveItemProgress(itemID string) {
defer multiMu.Unlock()
delete(multiProgress.Items, itemID)
markMultiProgressDirtyLocked()
}
func ClearAllItemProgress() {
@@ -200,7 +174,6 @@ func ClearAllItemProgress() {
defer multiMu.Unlock()
multiProgress.Items = make(map[string]*ItemProgress)
markMultiProgressDirtyLocked()
}
func setDownloadDir(path string) error {
+30 -726
View File
@@ -28,29 +28,13 @@ 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/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
@@ -59,8 +43,6 @@ const (
)
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,
@@ -76,406 +58,20 @@ 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"`
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"`
Image struct {
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))
@@ -790,232 +386,6 @@ 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,
@@ -1278,27 +648,6 @@ 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
}
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
queries := []string{}
@@ -1442,39 +791,6 @@ 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)
@@ -1931,19 +1247,6 @@ type QobuzDownloadResult struct {
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()
@@ -1957,20 +1260,17 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
var track *QobuzTrack
var err error
// Strategy 1: Use Qobuz ID from request payload (fastest, most accurate)
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
if req.QobuzID != "" {
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)
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)
if err != nil {
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
GoLog("[%s] Failed to get track by Odesli ID %d: %v\n", logPrefix, trackID, err)
track = nil
} else if track != nil {
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
}
GoLog("[%s] Successfully found track via Odesli ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
}
}
}
@@ -1979,12 +1279,10 @@ 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 = qobuzGetTrackByIDFunc(downloader, cached.QobuzTrackID)
track, err = downloader.GetTrackByID(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
}
}
}
@@ -1993,23 +1291,19 @@ 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 := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
availability, slErr := songLinkClient.CheckTrackAvailability(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 = qobuzGetTrackByIDFunc(downloader, trackID)
track, err = downloader.GetTrackByID(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 {
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
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)
}
}
}
@@ -2019,17 +1313,27 @@ 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 = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") {
track = nil
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
}
}
}
// 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 = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") {
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 = nil
}
}
-276
View File
@@ -2,95 +2,6 @@ package gobackend
import "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(`
<div class="product__item">
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
</div>
<div class="product__item">
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
</div>
<div class="product__item">
<button data-itemtype="album" data-itemId="0886446451985"></button>
</div>
`)
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) {
body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`)
@@ -195,22 +106,6 @@ func TestGetQobuzDebugKey(t *testing.T) {
}
}
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
body := []byte(`
<button data-itemtype="album" data-itemId="0886446451985"></button>
<button data-itemtype="album" data-itemId="0886446451985"></button>
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
`)
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 {
@@ -238,174 +133,3 @@ 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)
}
}
-2
View File
@@ -157,8 +157,6 @@ type AlbumResponsePayload struct {
}
type PlaylistInfoMetadata struct {
Name string `json:"name,omitempty"`
Images string `json:"images,omitempty"`
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
+17 -760
View File
@@ -14,7 +14,6 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -33,12 +32,6 @@ var (
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 {
@@ -50,28 +43,19 @@ 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 {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Picture string `json:"picture"`
Name string `json:"name"`
} `json:"artists"`
Artist struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Picture string `json:"picture"`
Name string `json:"name"`
} `json:"artist"`
MediaMetadata struct {
Tags []string `json:"tags"`
} `json:"mediaMetadata"`
URL string `json:"url"`
}
type TidalAPIResponseV2 struct {
@@ -116,105 +100,6 @@ 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{
@@ -229,457 +114,6 @@ 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",
@@ -769,183 +203,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
}
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
}
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 {
URL string
BitDepth int
@@ -1326,7 +583,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(DownloadTimeout)
client := NewHTTPClientWithTimeout(120 * time.Second)
if directURL != "" {
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
@@ -1805,18 +1062,20 @@ func isLatinScript(s string) bool {
return true
}
func parseTidalRequestTrackID(raw string) (int64, bool) {
trimmed := strings.TrimSpace(raw)
trimmed = strings.TrimPrefix(trimmed, "tidal:")
if trimmed == "" {
return 0, false
func tidalTrackArtistsDisplay(track *TidalTrack) string {
if track == nil {
return ""
}
trackID, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil || trackID <= 0 {
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, ", ")
}
return trackID, true
return tidalArtist
}
func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloader, logPrefix string) (*TidalTrack, error) {
@@ -1832,9 +1091,8 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
var gotTidalID bool
if req.TidalID != "" {
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
if parsedTrackID, ok := parseTidalRequestTrackID(req.TidalID); ok {
trackID = parsedTrackID
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 {
gotTidalID = true
}
}
@@ -1855,8 +1113,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return
}
if availability.TidalID != "" {
if parsedTrackID, ok := parseTidalRequestTrackID(availability.TidalID); ok {
trackID = parsedTrackID
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true
return
-222
View File
@@ -1,222 +0,0 @@
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)
}
}
+5 -1
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"sync"
"time"
)
type YouTubeDownloader struct {
@@ -81,7 +82,7 @@ type YouTubeDownloadResult struct {
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(DownloadTimeout),
client: NewHTTPClientWithTimeout(120 * time.Second),
apiURL: "https://api.qwkuns.me",
}
})
@@ -510,10 +511,12 @@ 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 {
@@ -521,6 +524,7 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
}
}
// /v/
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
-54
View File
@@ -383,22 +383,6 @@ 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
@@ -406,13 +390,6 @@ 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
@@ -623,20 +600,6 @@ 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]
@@ -828,23 +791,6 @@ 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
+2 -7
View File
@@ -1,14 +1,9 @@
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.8.0';
static const String buildNumber = '106';
static const String version = '3.7.2';
static const String buildNumber = '105';
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';
+1 -613
View File
@@ -256,7 +256,7 @@ abstract class AppLocalizations {
/// **'Filename Format'**
String get downloadFilenameFormat;
/// Title of the folder organization picker bottom sheet
/// Setting for folder structure
///
/// In en, this message translates to:
/// **'Folder Organization'**
@@ -2236,84 +2236,6 @@ 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:
@@ -4283,540 +4205,6 @@ 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);
/// Title of the Download All confirmation dialog
///
/// 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);
/// Confirm button in Download All dialog
///
/// In en, this message translates to:
/// **'Download'**
String get dialogDownload;
/// 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 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;
/// 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;
/// 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;
/// 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 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 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 option
///
/// In en, this message translates to:
/// **'Smallest size, ~4MB per track'**
String get downloadLossyOpus128Subtitle;
/// 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;
}
class _AppLocalizationsDelegate
-354
View File
@@ -1215,47 +1215,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get storeClearFilters => 'Filter entfernen';
@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 => 'Standard (Deezer/Spotify)';
@@ -2477,317 +2436,4 @@ class AppLocalizationsDe 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1195,47 +1195,6 @@ class AppLocalizationsEn 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)';
@@ -2449,317 +2408,4 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1195,47 +1195,6 @@ 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)';
@@ -2449,319 +2408,6 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
-354
View File
@@ -1197,47 +1197,6 @@ 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)';
@@ -2451,317 +2410,4 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1195,47 +1195,6 @@ 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)';
@@ -2449,317 +2408,4 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1200,47 +1200,6 @@ 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)';
@@ -2456,317 +2415,4 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1189,47 +1189,6 @@ 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)';
@@ -2436,317 +2395,4 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1175,47 +1175,6 @@ 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)';
@@ -2429,317 +2388,4 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1195,47 +1195,6 @@ 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)';
@@ -2449,317 +2408,4 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1195,47 +1195,6 @@ 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)';
@@ -2449,319 +2408,6 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
-354
View File
@@ -1216,47 +1216,6 @@ 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)';
@@ -2508,317 +2467,4 @@ class AppLocalizationsRu extends AppLocalizations {
@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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1206,47 +1206,6 @@ 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)';
@@ -2461,317 +2420,4 @@ 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
-354
View File
@@ -1195,47 +1195,6 @@ class AppLocalizationsZh 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)';
@@ -2449,319 +2408,6 @@ class AppLocalizationsZh 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 dialogDownload => 'Download';
@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 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 downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@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 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 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';
}
/// The translations for Chinese, as used in China (`zh_CN`).
-452
View File
@@ -1570,58 +1570,6 @@
"@storeClearFilters": {
"description": "Button to clear all filters"
},
"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/Spotify)",
"@extensionDefaultProvider": {
"description": "Default search provider option"
@@ -3257,405 +3205,5 @@
"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"
},
"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"
},
"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"
},
"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"
},
"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 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 option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps option"
},
"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"
}
}
+5 -65
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
@@ -9,7 +8,6 @@ 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';
@@ -91,72 +89,14 @@ class _EagerInitialization extends ConsumerStatefulWidget {
}
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
ProviderSubscription<bool>? _localLibraryEnabledSub;
Timer? _downloadHistoryWarmupTimer;
Timer? _libraryCollectionsWarmupTimer;
Timer? _localLibraryWarmupTimer;
bool _localLibraryWarmupScheduled = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_initializeAppServices();
_initializeExtensions();
_initializeDeferredProviders();
});
}
@override
void dispose() {
_localLibraryEnabledSub?.close();
_downloadHistoryWarmupTimer?.cancel();
_libraryCollectionsWarmupTimer?.cancel();
_localLibraryWarmupTimer?.cancel();
super.dispose();
}
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<bool>(
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),
);
_initializeAppServices();
_initializeExtensions();
ref.read(downloadHistoryProvider);
ref.read(localLibraryProvider);
ref.read(libraryCollectionsProvider);
}
Future<void> _initializeAppServices() async {
+44 -112
View File
@@ -205,7 +205,6 @@ class DownloadHistoryState {
final List<DownloadHistoryItem> items;
final Map<String, DownloadHistoryItem> _bySpotifyId;
final Map<String, DownloadHistoryItem> _byIsrc;
final Map<String, DownloadHistoryItem> _byTrackArtistKey;
DownloadHistoryState({this.items = const []})
: _bySpotifyId = Map.fromEntries(
@@ -219,25 +218,8 @@ 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) =>
@@ -249,9 +231,16 @@ class DownloadHistoryState {
String trackName,
String artistName,
) {
final key = _trackArtistKey(trackName, artistName);
if (key.isEmpty) return null;
return _byTrackArtistKey[key];
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;
}
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
@@ -263,12 +252,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
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() {
@@ -305,45 +292,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from SQLite database');
_scheduleStartupMaintenance(items);
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,
);
});
}
} catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack);
}
}
void _scheduleStartupMaintenance(List<DownloadHistoryItem> initialItems) {
if (_startupMaintenanceScheduled) {
return;
}
_startupMaintenanceScheduled = true;
unawaited(
Future<void>.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);
@@ -929,12 +904,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
int _downloadCount = 0;
static const _cleanupInterval = 50;
static const _progressPollingInterval = Duration(milliseconds: 1200);
static const _progressPollingInterval = Duration(milliseconds: 800);
static const _idleProgressPollEveryTicks = 3;
static const _queueSchedulingInterval = Duration(milliseconds: 250);
static const _queuePersistDebounceDuration = Duration(milliseconds: 350);
static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI.
static const _serviceProgressStepPercent = 2;
final NotificationService _notificationService = NotificationService();
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
int _totalQueuedAtStart = 0;
@@ -1471,17 +1445,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
.round()
.clamp(0, 100)
.toInt();
final progressBucket = progressPercent == 100
? 100
: ((progressPercent ~/ _serviceProgressStepPercent) *
_serviceProgressStepPercent)
.clamp(0, 100);
final didContentChange =
trackName != _lastServiceTrackName ||
artistName != _lastServiceArtistName ||
queueCount != _lastServiceQueueCount ||
progressBucket != _lastServicePercent;
progressPercent != _lastServicePercent;
final allowHeartbeat =
now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5);
@@ -1491,7 +1460,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_lastServiceTrackName = trackName;
_lastServiceArtistName = artistName;
_lastServicePercent = progressBucket;
_lastServicePercent = progressPercent;
_lastServiceQueueCount = queueCount;
_lastServiceUpdateAt = now;
@@ -1943,12 +1912,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
String addToQueue(
Track track,
String service, {
String? qualityOverride,
String? playlistName,
}) {
String addToQueue(Track track, String service, {String? qualityOverride, String? playlistName}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
@@ -2373,25 +2337,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendAlbum = normalizeOptionalString(
backendResult['album'] as String?,
);
final backendIsrc = normalizeOptionalString(
backendResult['isrc'] as String?,
);
final backendCoverUrl = normalizeOptionalString(
backendResult['cover_url'] as String?,
);
final backendAlbumArtist = normalizeOptionalString(
backendResult['album_artist'] as String?,
);
final hasOverrides = backendTrackNum != null ||
backendDiscNum != null ||
backendYear != null ||
backendAlbum != null ||
backendIsrc != null ||
backendCoverUrl != null ||
backendAlbumArtist != null;
if (!hasOverrides) {
if (backendTrackNum == null &&
backendDiscNum == null &&
backendYear == null &&
backendAlbum == null) {
return baseTrack;
}
@@ -2400,12 +2350,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: baseTrack.name,
artistName: baseTrack.artistName,
albumName: backendAlbum ?? baseTrack.albumName,
albumArtist: backendAlbumArtist ?? resolvedAlbumArtist,
albumArtist: resolvedAlbumArtist,
artistId: baseTrack.artistId,
albumId: baseTrack.albumId,
coverUrl: backendCoverUrl ?? baseTrack.coverUrl,
coverUrl: baseTrack.coverUrl,
duration: baseTrack.duration,
isrc: backendIsrc ?? baseTrack.isrc,
isrc: baseTrack.isrc,
trackNumber: backendTrackNum ?? baseTrack.trackNumber,
discNumber: backendDiscNum ?? baseTrack.discNumber,
releaseDate: backendYear ?? baseTrack.releaseDate,
@@ -3625,26 +3575,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? trackToDownload.discNumber!
: 1;
String payloadSpotifyId = trackToDownload.id;
String payloadQobuzId = '';
String payloadTidalId = '';
if (trackToDownload.id.startsWith('qobuz:')) {
payloadQobuzId = trackToDownload.id.substring(6);
if (item.service == 'qobuz') {
payloadSpotifyId = '';
}
}
if (trackToDownload.id.startsWith('tidal:')) {
payloadTidalId = trackToDownload.id.substring(6);
if (item.service == 'tidal') {
payloadSpotifyId = '';
}
}
final payload = DownloadRequestPayload(
isrc: trackToDownload.isrc ?? '',
service: item.service,
spotifyId: payloadSpotifyId,
spotifyId: trackToDownload.id,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
@@ -3668,8 +3602,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
genre: genre ?? '',
label: label ?? '',
copyright: copyright ?? '',
qobuzId: payloadQobuzId,
tidalId: payloadTidalId,
deezerId: deezerTrackId ?? '',
lyricsMode: settings.lyricsMode,
storageMode: storageMode,
+3 -5
View File
@@ -892,7 +892,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final providers = ['deezer', 'qobuz', 'tidal'];
final providers = ['deezer'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
@@ -911,10 +911,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
if (!result.contains(provider)) {
result.add(provider);
}
if (!result.contains('deezer')) {
result.insert(0, 'deezer');
}
return result;
+16 -36
View File
@@ -120,7 +120,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 1200);
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer;
Timer? _progressStreamBootstrapTimer;
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
@@ -315,6 +315,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
int skippedDownloads = 0;
for (final json in results) {
final filePath = json['filePath'] as String?;
// Skip files that are already in download history
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
@@ -378,41 +379,18 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
}
final useSnapshotBridge =
Platform.isAndroid && existingFiles.isNotEmpty;
final snapshotPath = useSnapshotBridge
? await _db.writeFileModTimesSnapshot()
: null;
Map<String, dynamic> result;
try {
if (isSaf) {
result = useSnapshotBridge && snapshotPath != null
? await PlatformBridge.scanSafTreeIncrementalFromSnapshot(
effectiveFolderPath,
snapshotPath,
)
: await PlatformBridge.scanSafTreeIncremental(
effectiveFolderPath,
existingFiles,
);
} else {
result = useSnapshotBridge && snapshotPath != null
? await PlatformBridge.scanLibraryFolderIncrementalFromSnapshot(
effectiveFolderPath,
snapshotPath,
)
: await PlatformBridge.scanLibraryFolderIncremental(
effectiveFolderPath,
existingFiles,
);
}
} finally {
if (snapshotPath != null) {
try {
await File(snapshotPath).delete();
} catch (_) {}
}
// Use appropriate incremental scan method based on SAF or not
final Map<String, dynamic> result;
if (isSaf) {
result = await PlatformBridge.scanSafTreeIncremental(
effectiveFolderPath,
existingFiles,
);
} else {
result = await PlatformBridge.scanLibraryFolderIncremental(
effectiveFolderPath,
existingFiles,
);
}
if (_scanCancelRequested) {
@@ -421,6 +399,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
// Parse incremental scan result
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList =
(result['files'] as List<dynamic>?) ??
@@ -486,6 +465,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
}
// Delete removed items
if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths);
for (final path in deletedPaths) {
+1 -1
View File
@@ -70,7 +70,7 @@ class RecentAccessItem {
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds;
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
final bool isLoaded;
const RecentAccessState({
+2 -80
View File
@@ -1,5 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -7,7 +6,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
final _log = AppLogger('StoreProvider');
final RegExp _leadingVersionPrefix = RegExp(r'^v');
const _registryUrlPrefKey = 'store_registry_url';
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
@@ -127,7 +125,6 @@ class StoreState {
final String? downloadingId;
final String? error;
final bool isInitialized;
final String registryUrl;
const StoreState({
this.extensions = const [],
@@ -138,12 +135,8 @@ class StoreState {
this.downloadingId,
this.error,
this.isInitialized = false,
this.registryUrl = '',
});
/// Whether a registry URL has been configured by the user.
bool get hasRegistryUrl => registryUrl.isNotEmpty;
StoreState copyWith({
List<StoreExtension>? extensions,
String? selectedCategory,
@@ -156,7 +149,6 @@ class StoreState {
String? error,
bool clearError = false,
bool? isInitialized,
String? registryUrl,
}) {
return StoreState(
extensions: extensions ?? this.extensions,
@@ -167,7 +159,6 @@ class StoreState {
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
error: clearError ? null : (error ?? this.error),
isInitialized: isInitialized ?? this.isInitialized,
registryUrl: registryUrl ?? this.registryUrl,
);
}
@@ -210,84 +201,15 @@ class StoreNotifier extends Notifier<StoreState> {
try {
await PlatformBridge.initExtensionStore(cacheDir);
// Load saved registry URL from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
if (savedUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(savedUrl);
state = state.copyWith(registryUrl: savedUrl);
await refresh();
}
await refresh();
state = state.copyWith(isInitialized: true, isLoading: false);
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})');
_log.i('Extension store initialized');
} catch (e) {
_log.e('Failed to initialize store: $e');
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Sets the registry URL, saves it, and refreshes the store.
/// The Go backend handles URL normalisation (GitHub repo raw URL, branch detection).
Future<void> setRegistryUrl(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) {
state = state.copyWith(error: 'Please enter a valid URL');
return;
}
state = state.copyWith(isLoading: true, clearError: true);
try {
// Go backend resolves GitHub URLs (detects default branch) and validates HTTPS.
await PlatformBridge.setStoreRegistryUrl(trimmed);
// Read back the resolved URL (may differ from input after normalisation).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
// Persist to SharedPreferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
state = state.copyWith(
registryUrl: resolvedUrl,
extensions: const [], // Clear old extensions
);
_log.i('Registry URL set to: $resolvedUrl');
await refresh(forceRefresh: true);
} catch (e) {
_log.e('Failed to set registry URL: $e');
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// Removes the saved registry URL and fully detaches the repo from backend.
Future<void> removeRegistryUrl() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_registryUrlPrefKey);
// Reset the URL in Go backend memory AND clear its cache
await PlatformBridge.clearStoreRegistryUrl();
state = state.copyWith(
registryUrl: '',
extensions: const [],
clearCategory: true,
searchQuery: '',
clearError: true,
);
_log.i('Registry URL removed');
} catch (e) {
_log.e('Failed to remove registry URL: $e');
state = state.copyWith(error: e.toString());
}
}
Future<void> refresh({bool forceRefresh = false}) async {
state = state.copyWith(isLoading: true, clearError: true);
+112 -163
View File
@@ -18,7 +18,7 @@ class TrackState {
final String? artistName;
final String? coverUrl;
final String? headerImageUrl; // Artist header image for background
final int? monthlyListeners;
final int? monthlyListeners; // Artist monthly listeners
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
@@ -384,76 +384,6 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
if (url.contains('qobuz.com') || url.startsWith('qobuzapp://')) {
_log.i('Detected Qobuz URL, parsing...');
final parsed = await PlatformBridge.parseQobuzUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
final metadata = await PlatformBridge.getQobuzMetadata(type, id);
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: 'qobuz:$id',
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo =
metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
);
}
return;
}
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
@@ -462,65 +392,68 @@ class TrackNotifier extends Notifier<TrackState> {
final type = parsed['type'] as String;
final id = parsed['id'] as String;
final metadata = await PlatformBridge.getTidalMetadata(type, id);
if (!_isRequestValid(requestId)) return;
_log.i('Tidal URL parsed: type=$type, id=$id');
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: 'tidal:$id',
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo =
metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
);
try {
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
url,
);
if (!_isRequestValid(requestId)) return;
final spotifyUrl = conversion['spotify_url'] as String?;
final deezerUrl = conversion['deezer_url'] as String?;
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
final metadata =
await PlatformBridge.getSpotifyMetadataWithFallback(
spotifyUrl,
);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
final deezerParsed = await PlatformBridge.parseDeezerUrl(
deezerUrl,
);
final metadata = await PlatformBridge.getDeezerMetadata(
'track',
deezerParsed['id'] as String,
);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
}
} catch (e) {
_log.w('Failed to convert Tidal URL via SongLink: $e');
}
}
// For album/artist/playlist, not yet supported
state = TrackState(
isLoading: false,
error:
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
hasSearchText: state.hasSearchText,
);
return;
}
@@ -582,15 +515,11 @@ class TrackNotifier extends Notifier<TrackState> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
@@ -637,31 +566,41 @@ class TrackNotifier extends Notifier<TrackState> {
final hasActiveMetadataExtensions = extensionState.extensions.any(
(e) => e.enabled && e.hasMetadataProvider,
);
final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions;
final searchProvider = settings.searchProvider;
final useExtensions =
settings.useExtensionProviders &&
hasActiveMetadataExtensions &&
searchProvider != null &&
searchProvider.isNotEmpty;
const source = 'deezer';
_log.i(
'Search started: metadataProviders, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = [];
List<Track> extensionTracks = [];
try {
_log.d('Calling metadata provider search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'Metadata providers returned ${metadataTrackResults.length} tracks',
);
} catch (e) {
_log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
if (useExtensions) {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(
query,
limit: 20,
);
_log.i('Extensions returned ${extResults.length} tracks');
for (final t in extResults) {
try {
extensionTracks.add(_parseSearchTrack(t));
} catch (e) {
_log.e('Failed to parse extension track: $e', e);
}
}
} catch (e) {
_log.w('Extension search failed, falling back to Deezer: $e');
}
}
_log.d('Calling Deezer search API...');
@@ -683,20 +622,32 @@ class TrackNotifier extends Notifier<TrackState> {
final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
final albumList = results['albums'] as List<dynamic>? ?? [];
final trackSearchResults = metadataTrackResults.isNotEmpty
? metadataTrackResults
: trackList.whereType<Map<String, dynamic>>().toList();
_log.d(
'Raw results: ${trackSearchResults.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
);
final tracks = <Track>[];
for (int i = 0; i < trackSearchResults.length; i++) {
final t = trackSearchResults[i];
tracks.addAll(extensionTracks);
final existingIsrcs = extensionTracks
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
.map((t) => t.isrc!)
.toSet();
for (int i = 0; i < trackList.length; i++) {
final t = trackList[i];
try {
tracks.add(_parseSearchTrack(t));
if (t is Map<String, dynamic>) {
final track = _parseSearchTrack(t);
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
continue;
}
tracks.add(track);
} else {
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
}
} catch (e) {
_log.e('Failed to parse track[$i]: $e', e);
}
@@ -746,7 +697,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
_log.i(
'Search complete: ${tracks.length} tracks, ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
);
state = TrackState(
@@ -933,10 +884,8 @@ class TrackNotifier extends Notifier<TrackState> {
Track _parseTrack(Map<String, dynamic> data) {
final durationMs = _extractDurationMs(data);
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
return Track(
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
id: data['spotify_id'] as String? ?? '',
name: data['name'] as String? ?? '',
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
+5 -24
View File
@@ -84,12 +84,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
// Use extensionId if available, otherwise detect from albumId prefix
final providerId =
widget.extensionId ??
(() {
if (widget.albumId.startsWith('deezer:')) return 'deezer';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('tidal:')) return 'tidal';
return 'spotify';
})();
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref
.read(recentAccessProvider.notifier)
.recordAlbumAccess(
@@ -180,12 +175,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
'album',
deezerAlbumId,
);
} else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId);
} else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId);
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
@@ -630,9 +619,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
size: 22,
color: allLoved ? Colors.redAccent : Colors.white,
),
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
padding: EdgeInsets.zero,
),
);
@@ -655,7 +642,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
icon: const Icon(Icons.add, size: 22, color: Colors.white),
tooltip: context.l10n.tooltipAddToPlaylist,
tooltip: 'Add to Playlist',
padding: EdgeInsets.zero,
),
);
@@ -673,11 +660,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
),
),
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
);
}
} else {
@@ -690,9 +673,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
SnackBar(content: Text('Added $addedCount tracks to Loved')),
);
}
}
+16 -185
View File
@@ -38,14 +38,12 @@ class _ArtistCache {
static void set(
String artistId, {
required List<ArtistAlbum> albums,
List<ArtistAlbum>? releases,
List<Track>? topTracks,
String? headerImageUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
monthlyListeners: monthlyListeners,
@@ -56,7 +54,6 @@ class _ArtistCache {
class _CacheEntry {
final List<ArtistAlbum> albums;
final List<ArtistAlbum>? releases;
final List<Track>? topTracks;
final String? headerImageUrl;
final int? monthlyListeners;
@@ -64,7 +61,6 @@ class _CacheEntry {
_CacheEntry({
required this.albums,
this.releases,
this.topTracks,
this.headerImageUrl,
this.monthlyListeners,
@@ -101,7 +97,6 @@ class ArtistScreen extends ConsumerStatefulWidget {
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _isLoadingDiscography = false;
List<ArtistAlbum>? _albums;
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
String? _headerImageUrl;
int? _monthlyListeners;
@@ -109,8 +104,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
final PageController _popularPageController = PageController();
int _popularCurrentPage = 0;
bool _isSelectionMode = false;
final Set<String> _selectedAlbumIds = {};
@@ -160,12 +153,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId =
widget.extensionId ??
(() {
if (widget.artistId.startsWith('deezer:')) return 'deezer';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('tidal:')) return 'tidal';
return 'spotify';
})();
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref
.read(recentAccessProvider.notifier)
.recordArtistAccess(
@@ -181,11 +169,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
if ((_albums == null || _albums!.isEmpty) ||
(_topTracks == null || _topTracks!.isEmpty)) {
_fetchDiscography();
}
return;
}
@@ -202,7 +185,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
} else if (cached != null) {
_albums = cached.albums;
_releases = cached.releases;
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_monthlyListeners = cached.monthlyListeners;
@@ -227,7 +209,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_popularPageController.dispose();
super.dispose();
}
@@ -235,7 +216,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
setState(() => _isLoadingDiscography = true);
try {
List<ArtistAlbum> albums;
List<ArtistAlbum>? releases;
List<Track>? topTracks;
String? headerImage;
int? listeners;
@@ -250,66 +230,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
} else if (widget.artistId.startsWith('qobuz:')) {
final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata(
'artist',
qobuzArtistId,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
headerImage = artistInfo?['images'] as String?;
} else if (widget.artistId.startsWith('tidal:')) {
final tidalArtistId = widget.artistId.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata(
'artist',
tidalArtistId,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
headerImage = artistInfo?['images'] as String?;
} else if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
final result = await PlatformBridge.getArtistWithExtension(
widget.extensionId!,
widget.artistId,
);
if (result == null) {
throw Exception('Failed to load artist from extension');
}
final artistData = result;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final releasesList = artistData['releases'] as List<dynamic>? ?? [];
if (releasesList.isNotEmpty) {
releases = releasesList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
}
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
}
headerImage =
artistData['header_image'] as String? ??
artistData['cover_url'] as String? ??
artistData['image_url'] as String?;
listeners = artistData['listeners'] as int?;
} else {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final result = await PlatformBridge.handleURLWithExtension(url);
@@ -350,7 +270,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_ArtistCache.set(
widget.artistId,
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
monthlyListeners: finalListeners,
@@ -359,7 +278,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (mounted) {
setState(() {
_albums = albums;
_releases = releases;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_monthlyListeners = finalListeners;
@@ -385,11 +303,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
durationMs = durationValue.toInt();
}
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
return Track(
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
@@ -408,28 +323,20 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
releaseDate: data['release_date']?.toString(),
albumType: data['album_type']?.toString() ?? album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
source: data['provider_id']?.toString() ?? widget.extensionId,
source: data['provider_id']?.toString(),
);
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
final totalTracksValue = data['total_tracks'];
final totalTracks =
totalTracksValue is int
? totalTracksValue
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: (data['name'] ?? data['title'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: totalTracks,
coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art'])
?.toString(),
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
.toString(),
providerId: data['provider_id']?.toString() ?? widget.extensionId,
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
);
}
@@ -452,7 +359,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final colorScheme = Theme.of(context).colorScheme;
final albums = _albums ?? [];
_ensureAlbumBuckets(albums);
final releases = _releases ?? const <ArtistAlbum>[];
final albumsOnly = _albumsOnlyBucket;
final singles = _singlesBucket;
final compilations = _compilationsBucket;
@@ -498,14 +404,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
SliverToBoxAdapter(
child: _buildPopularSection(colorScheme),
),
if (releases.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
'Releases',
releases,
colorScheme,
),
),
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
@@ -1063,24 +961,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album))
.toList();
}
} else if (album.id.startsWith('qobuz:')) {
final qobuzId = album.id.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else if (album.id.startsWith('tidal:')) {
final tidalId = album.id.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata('album', tidalId);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else {
final url = 'https://open.spotify.com/album/${album.id}';
final result = await PlatformBridge.handleURLWithExtension(url);
@@ -1331,9 +1211,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return const SizedBox.shrink();
}
final tracks = _topTracks!;
const tracksPerPage = 5;
final pageCount = (tracks.length / tracksPerPage).ceil();
final tracks = _topTracks!.take(5).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1347,58 +1225,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
SizedBox(
height: tracksPerPage * 64.0,
child: PageView.builder(
controller: _popularPageController,
itemCount: pageCount,
onPageChanged: (page) {
setState(() {
_popularCurrentPage = page;
});
},
itemBuilder: (context, pageIndex) {
final startIndex = pageIndex * tracksPerPage;
final endIndex =
(startIndex + tracksPerPage).clamp(0, tracks.length);
final pageTracks = tracks.sublist(startIndex, endIndex);
return Column(
children: pageTracks.asMap().entries.map((entry) {
final globalIndex = startIndex + entry.key;
return _buildPopularTrackItem(
globalIndex + 1,
entry.value,
colorScheme,
);
}).toList(),
);
},
),
),
if (pageCount > 1)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(pageCount, (index) {
final isActive = _popularCurrentPage == index;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 8 : 6,
height: isActive ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? colorScheme.primary
: colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
),
);
}),
),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return _buildPopularTrackItem(index + 1, track, colorScheme);
}),
],
);
}
+2 -1
View File
@@ -125,6 +125,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
// Use lowercase for case-insensitive matching
final itemKey =
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
return itemKey == _albumLookupKey;
@@ -362,7 +363,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
body: Center(child: Text('No tracks found for this album')),
);
}
+11 -39
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -82,37 +81,6 @@ class _SearchResultBuckets {
});
}
const _homeHistoryPreviewLimit = 48;
class _HomeHistoryPreview {
final List<DownloadHistoryItem> items;
const _HomeHistoryPreview(this.items);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _HomeHistoryPreview && listEquals(items, other.items);
@override
int get hashCode => Object.hashAll(items);
}
final _homeHistoryPreviewProvider = Provider<List<DownloadHistoryItem>>((ref) {
final preview = ref.watch(
downloadHistoryProvider.select((s) {
final items = s.items;
if (items.length <= _homeHistoryPreviewLimit) {
return _HomeHistoryPreview(items);
}
return _HomeHistoryPreview(
items.take(_homeHistoryPreviewLimit).toList(growable: false),
);
}),
);
return preview.items;
});
_RecentAccessView _buildRecentAccessViewData(
List<RecentAccessItem> items,
List<DownloadHistoryItem> historyItems,
@@ -196,7 +164,9 @@ _RecentAccessView _buildRecentAccessViewData(
}
final recentAccessViewProvider = Provider<_RecentAccessView>((ref) {
final historyItems = ref.watch(_homeHistoryPreviewProvider);
final historyItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final recentAccessItems = ref.watch(
recentAccessProvider.select((s) => s.items),
);
@@ -846,7 +816,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
const SizedBox(height: 12),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.homeSkipAlreadyDownloaded),
title: const Text('Skip already downloaded songs'),
value: skipDownloaded,
onChanged: (value) {
setDialogState(() {
@@ -1017,7 +987,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
final mediaQuery = MediaQuery.of(context);
final screenHeight = mediaQuery.size.height;
final topPadding = normalizedHeaderTopPadding(context);
final historyItems = ref.watch(_homeHistoryPreviewProvider);
final historyItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
final showRecentAccess =
@@ -1778,7 +1750,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
ListTile(
leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
title: Text(context.l10n.homeGoToAlbum),
title: const Text('Go to Album'),
onTap: () {
Navigator.pop(context);
_navigateToTrackAlbum(item);
@@ -1850,9 +1822,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Album info not available')));
}
}
@@ -850,8 +850,8 @@ class _LibraryTracksFolderScreenState
final colorScheme = Theme.of(dialogContext).colorScheme;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(context.l10n.dialogDownloadAllTitle),
content: Text(context.l10n.dialogDownloadAllMessage(tracks.length)),
title: const Text('Download All'),
content: Text('Download ${tracks.length} tracks?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
@@ -862,7 +862,7 @@ class _LibraryTracksFolderScreenState
Navigator.pop(dialogContext);
_downloadAll(tracks);
},
child: Text(context.l10n.dialogDownload),
child: const Text('Download'),
),
],
);
+1 -1
View File
@@ -247,7 +247,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
body: const Center(child: Text('No tracks found for this album')),
);
}
+26 -48
View File
@@ -33,7 +33,7 @@ class MainShell extends ConsumerStatefulWidget {
class _MainShellState extends ConsumerState<MainShell> {
int _currentIndex = 0;
late final PageController _pageController;
late PageController _pageController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
@@ -113,18 +113,17 @@ class _MainShellState extends ConsumerState<MainShell> {
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit =
errorMsg.contains('429') ||
final isRateLimit = errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(displayMessage)));
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(displayMessage)),
);
}
}
@@ -159,10 +158,12 @@ class _MainShellState extends ConsumerState<MainShell> {
if (settings.storageMode == 'saf') return;
if (settings.downloadDirectory.isEmpty) return;
// Check Android version
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
if (androidInfo.version.sdkInt < 29) return;
// Only show once
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_safMigrationShownKey) == true) return;
await prefs.setBool(_safMigrationShownKey, true);
@@ -180,20 +181,25 @@ class _MainShellState extends ConsumerState<MainShell> {
size: 32,
color: colorScheme.primary,
),
title: Text(context.l10n.safMigrationTitle),
content: Column(
title: const Text('Storage Update Required'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.safMigrationMessage1),
const SizedBox(height: 12),
Text(context.l10n.safMigrationMessage2),
Text(
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. '
'This fixes "permission denied" errors on Android 10+.',
),
SizedBox(height: 12),
Text(
'Please select your download folder again to switch to the new storage system.',
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(context.l10n.updateLater),
child: const Text('Later'),
),
FilledButton(
onPressed: () async {
@@ -213,13 +219,15 @@ class _MainShellState extends ConsumerState<MainShell> {
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.safMigrationSuccess)),
const SnackBar(
content: Text('Download folder updated to SAF mode'),
),
);
}
}
}
},
child: Text(context.l10n.setupSelectFolder),
child: const Text('Select Folder'),
),
],
),
@@ -252,7 +260,6 @@ class _MainShellState extends ConsumerState<MainShell> {
}
if (_currentIndex != index) {
final shouldResetHome = index == 0;
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -262,10 +269,6 @@ class _MainShellState extends ConsumerState<MainShell> {
currentTabIndex: _currentIndex,
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
if (shouldResetHome) {
_resetHomeToMain();
}
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
@@ -505,15 +508,11 @@ class _MainShellState extends ConsumerState<MainShell> {
return true;
},
child: Scaffold(
body: PageView.builder(
body: PageView(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
),
children: tabs,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex),
@@ -574,27 +573,6 @@ class _LibraryTabRoot extends ConsumerWidget {
}
}
class _KeepAliveTabPage extends StatefulWidget {
final Widget child;
const _KeepAliveTabPage({super.key, required this.child});
@override
State<_KeepAliveTabPage> createState() => _KeepAliveTabPageState();
}
class _KeepAliveTabPageState extends State<_KeepAliveTabPage>
with AutomaticKeepAliveClientMixin<_KeepAliveTabPage> {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
}
class BouncingIcon extends StatefulWidget {
final Widget child;
const BouncingIcon({super.key, required this.child});
+23 -63
View File
@@ -39,12 +39,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
List<Track>? _fetchedTracks;
bool _isLoading = false;
String? _error;
String? _resolvedPlaylistName;
String? _resolvedCoverUrl;
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
@override
void initState() {
@@ -69,24 +65,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
});
try {
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
late final Map<String, dynamic> result;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
} else if (playlistId.startsWith('qobuz:')) {
playlistId = playlistId.substring(6);
result = await PlatformBridge.getQobuzMetadata('playlist', playlistId);
} else if (playlistId.startsWith('tidal:')) {
playlistId = playlistId.substring(6);
result = await PlatformBridge.getTidalMetadata('playlist', playlistId);
} else {
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
}
if (!mounted) return;
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
final result = await PlatformBridge.getDeezerMetadata(
'playlist',
playlistId,
);
if (!mounted) return;
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
@@ -96,10 +85,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
setState(() {
_fetchedTracks = tracks;
_resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name'])
?.toString();
_resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images'])
?.toString();
_isLoading = false;
});
} catch (e) {
@@ -199,7 +184,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
_playlistName,
widget.playlistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
@@ -221,9 +206,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
background: Stack(
fit: StackFit.expand,
children: [
if (_coverUrl != null)
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
imageUrl:
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
@@ -270,7 +256,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
_playlistName,
widget.playlistName,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
@@ -430,12 +416,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(
track,
service,
qualityOverride: quality,
playlistName: _playlistName,
);
.addToQueue(track, service, qualityOverride: quality, playlistName: widget.playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
@@ -446,11 +427,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(
track,
settings.defaultService,
playlistName: _playlistName,
);
.addToQueue(track, settings.defaultService, playlistName: widget.playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
@@ -505,9 +482,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
size: 22,
color: allLoved ? Colors.redAccent : Colors.white,
),
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
padding: EdgeInsets.zero,
),
);
@@ -530,7 +505,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Widget _buildAddToPlaylistButton(BuildContext context) {
return _buildCircleButton(
icon: Icons.playlist_add,
tooltip: context.l10n.tooltipAddToPlaylist,
tooltip: 'Add to Playlist',
onPressed: _tracks.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
@@ -545,8 +520,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final colorScheme = Theme.of(dialogContext).colorScheme;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(context.l10n.dialogDownloadAllTitle),
content: Text(context.l10n.dialogDownloadAllMessage(_tracks.length)),
title: const Text('Download All'),
content: Text('Download ${_tracks.length} tracks?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
@@ -557,7 +532,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Navigator.pop(dialogContext);
_downloadAll(context);
},
child: Text(context.l10n.dialogDownload),
child: const Text('Download'),
),
],
);
@@ -577,11 +552,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
),
),
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
);
}
} else {
@@ -594,9 +565,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
SnackBar(content: Text('Added $addedCount tracks to Loved')),
);
}
}
@@ -613,16 +582,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
artistName: _playlistName,
artistName: widget.playlistName,
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracks,
service,
qualityOverride: quality,
playlistName: _playlistName,
);
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: widget.playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -635,11 +599,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracks,
settings.defaultService,
playlistName: _playlistName,
);
.addMultipleToQueue(tracks, settings.defaultService, playlistName: widget.playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
+331 -358
View File
@@ -105,7 +105,7 @@ class UnifiedLibraryItem {
artistName: item.artistName,
albumName: item.albumName,
coverUrl: null, // Local library doesn't have cover URLs
localCoverPath: item.coverPath,
localCoverPath: item.coverPath, // Use extracted cover path
filePath: item.filePath,
quality: quality,
addedAt: item.fileModTime != null
@@ -314,348 +314,6 @@ class _QueueItemIdsSnapshot {
int get hashCode => Object.hashAll(ids);
}
class _QueueGroupedAlbumFilterRequest {
final String searchQuery;
final String? filterSource;
final String? filterQuality;
final String? filterFormat;
final String sortMode;
const _QueueGroupedAlbumFilterRequest({
required this.searchQuery,
required this.filterSource,
required this.filterQuality,
required this.filterFormat,
required this.sortMode,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _QueueGroupedAlbumFilterRequest &&
searchQuery == other.searchQuery &&
filterSource == other.filterSource &&
filterQuality == other.filterQuality &&
filterFormat == other.filterFormat &&
sortMode == other.sortMode;
@override
int get hashCode => Object.hash(
searchQuery,
filterSource,
filterQuality,
filterFormat,
sortMode,
);
}
String _queueFileExtLower(String filePath) {
final slashIndex = filePath.lastIndexOf('/');
final dotIndex = filePath.lastIndexOf('.');
if (dotIndex == -1 || dotIndex < slashIndex + 1) {
return '';
}
return filePath.substring(dotIndex + 1).toLowerCase();
}
String? _queueLocalQualityLabel(LocalLibraryItem item) {
if (item.bitrate != null && item.bitrate! > 0) {
return '${item.bitrate}kbps';
}
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
return null;
}
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
bool _queuePassesQualityFilter(String? filterQuality, String? quality) {
if (filterQuality == null) return true;
if (quality == null) return filterQuality == 'lossy';
final normalized = quality.toLowerCase();
switch (filterQuality) {
case 'hires':
return normalized.startsWith('24');
case 'cd':
return normalized.startsWith('16');
case 'lossy':
return !normalized.startsWith('24') && !normalized.startsWith('16');
default:
return true;
}
}
bool _queuePassesFormatFilter(String? filterFormat, String filePath) {
if (filterFormat == null) return true;
return _queueFileExtLower(filePath) == filterFormat;
}
_HistoryStats _buildQueueHistoryStats(
List<DownloadHistoryItem> items, [
List<LocalLibraryItem> localItems = const [],
]) {
final albumCounts = <String, int>{};
final albumMap = <String, List<DownloadHistoryItem>>{};
for (final item in items) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
albumMap.putIfAbsent(key, () => []).add(item);
}
var singleTracks = 0;
for (final item in items) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
if ((albumCounts[key] ?? 0) <= 1) {
singleTracks++;
}
}
final groupedAlbums = <_GroupedAlbum>[];
albumMap.forEach((_, tracks) {
if (tracks.length <= 1) return;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
groupedAlbums.add(
_GroupedAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverUrl: tracks.first.coverUrl,
sampleFilePath: tracks.first.filePath,
tracks: tracks,
latestDownload: tracks
.map((t) => t.downloadedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
),
);
});
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
var albumCount = 0;
for (final count in albumCounts.values) {
if (count > 1) albumCount++;
}
final downloadedPathKeys = <String>{};
for (final item in items) {
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
}
final dedupedLocalItems = localItems
.where((item) {
final localPathKeys = buildPathMatchKeys(item.filePath);
return !localPathKeys.any(downloadedPathKeys.contains);
})
.toList(growable: false);
final localAlbumCounts = <String, int>{};
final localAlbumMap = <String, List<LocalLibraryItem>>{};
for (final item in dedupedLocalItems) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1;
localAlbumMap.putIfAbsent(key, () => []).add(item);
}
var localAlbumCount = 0;
var localSingleTracks = 0;
for (final count in localAlbumCounts.values) {
if (count > 1) {
localAlbumCount++;
} else {
localSingleTracks++;
}
}
final groupedLocalAlbums = <_GroupedLocalAlbum>[];
localAlbumMap.forEach((_, tracks) {
if (tracks.length <= 1) return;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
groupedLocalAlbums.add(
_GroupedLocalAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverPath: tracks
.firstWhere(
(t) => t.coverPath != null && t.coverPath!.isNotEmpty,
orElse: () => tracks.first,
)
.coverPath,
tracks: tracks,
latestScanned: tracks
.map((t) => t.scannedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
),
);
});
groupedLocalAlbums.sort((a, b) => b.latestScanned.compareTo(a.latestScanned));
return _HistoryStats(
albumCounts: albumCounts,
localAlbumCounts: localAlbumCounts,
groupedAlbums: groupedAlbums,
groupedLocalAlbums: groupedLocalAlbums,
albumCount: albumCount,
singleTracks: singleTracks,
localAlbumCount: localAlbumCount,
localSingleTracks: localSingleTracks,
);
}
List<_GroupedAlbum> _queueFilterGroupedAlbums(
List<_GroupedAlbum> albums,
_QueueGroupedAlbumFilterRequest request,
) {
if (request.filterSource == 'local') return const [];
if (request.filterSource == null &&
request.filterQuality == null &&
request.filterFormat == null &&
request.searchQuery.isEmpty &&
request.sortMode == 'latest') {
return albums;
}
final result = <_GroupedAlbum>[];
for (final album in albums) {
if (request.searchQuery.isNotEmpty &&
!album.searchKey.contains(request.searchQuery)) {
continue;
}
if (request.filterQuality != null || request.filterFormat != null) {
var hasMatchingTrack = false;
for (final track in album.tracks) {
if (!_queuePassesQualityFilter(request.filterQuality, track.quality)) {
continue;
}
if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) {
continue;
}
hasMatchingTrack = true;
break;
}
if (!hasMatchingTrack) continue;
}
result.add(album);
}
switch (request.sortMode) {
case 'oldest':
result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload));
case 'a-z':
result.sort(
(a, b) =>
a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()),
);
case 'z-a':
result.sort(
(a, b) =>
b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()),
);
default:
break;
}
return result;
}
List<_GroupedLocalAlbum> _queueFilterGroupedLocalAlbums(
List<_GroupedLocalAlbum> albums,
_QueueGroupedAlbumFilterRequest request,
) {
if (request.filterSource == 'downloaded') return const [];
if (request.filterSource == null &&
request.filterQuality == null &&
request.filterFormat == null &&
request.searchQuery.isEmpty &&
request.sortMode == 'latest') {
return albums;
}
final result = <_GroupedLocalAlbum>[];
for (final album in albums) {
if (request.searchQuery.isNotEmpty &&
!album.searchKey.contains(request.searchQuery)) {
continue;
}
if (request.filterQuality != null || request.filterFormat != null) {
var hasMatchingTrack = false;
for (final track in album.tracks) {
if (!_queuePassesQualityFilter(
request.filterQuality,
_queueLocalQualityLabel(track),
)) {
continue;
}
if (!_queuePassesFormatFilter(request.filterFormat, track.filePath)) {
continue;
}
hasMatchingTrack = true;
break;
}
if (!hasMatchingTrack) continue;
}
result.add(album);
}
switch (request.sortMode) {
case 'oldest':
result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned));
case 'a-z':
result.sort(
(a, b) =>
a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()),
);
case 'z-a':
result.sort(
(a, b) =>
b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()),
);
default:
break;
}
return result;
}
final _queueHistoryStatsProvider = Provider<_HistoryStats>((ref) {
final historyItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final localLibraryEnabled = ref.watch(
settingsProvider.select((s) => s.localLibraryEnabled),
);
final localItems = localLibraryEnabled
? ref.watch(localLibraryProvider.select((s) => s.items))
: const <LocalLibraryItem>[];
return _buildQueueHistoryStats(historyItems, localItems);
});
final _queueFilteredAlbumsProvider =
Provider.family<
({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums}),
_QueueGroupedAlbumFilterRequest
>((ref, request) {
final historyStats = ref.watch(_queueHistoryStatsProvider);
return (
albums: _queueFilterGroupedAlbums(historyStats.groupedAlbums, request),
localAlbums: _queueFilterGroupedLocalAlbums(
historyStats.groupedLocalAlbums,
request,
),
);
});
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
final entries = (payload['entries'] as List).cast<List>();
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
@@ -773,6 +431,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String? _filterCacheQuality;
String? _filterCacheFormat;
String _filterCacheSortMode = 'latest';
_HistoryStats? _groupedAlbumFilterHistoryStatsCache;
String _groupedAlbumFilterSearchQuery = '';
String? _groupedAlbumFilterSource;
String? _groupedAlbumFilterQuality;
String? _groupedAlbumFilterFormat;
String _groupedAlbumFilterSortMode = 'latest';
List<_GroupedAlbum> _filteredGroupedAlbumsCache = const [];
List<_GroupedLocalAlbum> _filteredGroupedLocalAlbumsCache = const [];
// Advanced filters
String? _filterSource; // null = all, 'downloaded', 'local'
String? _filterQuality; // null = all, 'hires', 'cd', 'lossy'
@@ -883,7 +549,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
void _ensureHistoryCaches(
List<DownloadHistoryItem> items,
List<LocalLibraryItem> localItems,
_HistoryStats historyStats,
) {
final historyChanged = !identical(items, _historyItemsCache);
final localChanged = !identical(localItems, _localLibraryItemsCache);
@@ -892,7 +557,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
_historyItemsCache = items;
_localLibraryItemsCache = localItems;
_historyStatsCache = historyStats;
_historyStatsCache = _buildHistoryStats(items, localItems);
if (historyChanged) {
_searchIndexCache.clear();
}
@@ -1696,6 +1361,18 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return filePath.substring(dotIndex + 1).toLowerCase();
}
String? _localQualityLabel(LocalLibraryItem item) {
if (item.bitrate != null && item.bitrate! > 0) {
return '${item.bitrate}kbps';
}
if (item.bitDepth == null ||
item.bitDepth == 0 ||
item.sampleRate == null) {
return null;
}
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
}
List<UnifiedLibraryItem> _applyAdvancedFilters(
List<UnifiedLibraryItem> items,
) {
@@ -1768,6 +1445,179 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return sorted;
}
bool _passesQualityFilter(String? quality) {
if (_filterQuality == null) return true;
if (quality == null) return _filterQuality == 'lossy';
final q = quality.toLowerCase();
switch (_filterQuality) {
case 'hires':
return q.startsWith('24');
case 'cd':
return q.startsWith('16');
case 'lossy':
return !q.startsWith('24') && !q.startsWith('16');
default:
return true;
}
}
bool _passesFormatFilter(String filePath) {
if (_filterFormat == null) return true;
return _fileExtLower(filePath) == _filterFormat;
}
List<_GroupedAlbum> _filterGroupedAlbums(
List<_GroupedAlbum> albums,
String searchQuery,
) {
if (_activeFilterCount == 0 &&
searchQuery.isEmpty &&
_sortMode == 'latest') {
return albums;
}
// Source filter: if filtering local only, hide all download albums
if (_filterSource == 'local') return const [];
final result = <_GroupedAlbum>[];
for (final album in albums) {
if (searchQuery.isNotEmpty && !album.searchKey.contains(searchQuery)) {
continue;
}
// Filter tracks within the album by advanced filters
if (_filterQuality != null || _filterFormat != null) {
var hasMatchingTrack = false;
for (final track in album.tracks) {
if (!_passesQualityFilter(track.quality)) continue;
if (!_passesFormatFilter(track.filePath)) continue;
hasMatchingTrack = true;
break;
}
if (!hasMatchingTrack) continue;
}
result.add(album);
}
// Apply sorting to albums
switch (_sortMode) {
case 'oldest':
result.sort((a, b) => a.latestDownload.compareTo(b.latestDownload));
case 'a-z':
result.sort(
(a, b) =>
a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()),
);
case 'z-a':
result.sort(
(a, b) =>
b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()),
);
default: // 'latest' - already sorted
break;
}
return result;
}
List<_GroupedLocalAlbum> _filterGroupedLocalAlbums(
List<_GroupedLocalAlbum> albums,
String searchQuery,
) {
if (_activeFilterCount == 0 &&
searchQuery.isEmpty &&
_sortMode == 'latest') {
return albums;
}
// Source filter: if filtering downloaded only, hide all local albums
if (_filterSource == 'downloaded') return const [];
final result = <_GroupedLocalAlbum>[];
for (final album in albums) {
if (searchQuery.isNotEmpty && !album.searchKey.contains(searchQuery)) {
continue;
}
// Filter tracks within the album by advanced filters
if (_filterQuality != null || _filterFormat != null) {
var hasMatchingTrack = false;
for (final track in album.tracks) {
if (!_passesQualityFilter(_localQualityLabel(track))) continue;
if (!_passesFormatFilter(track.filePath)) continue;
hasMatchingTrack = true;
break;
}
if (!hasMatchingTrack) continue;
}
result.add(album);
}
// Apply sorting to local albums
switch (_sortMode) {
case 'oldest':
result.sort((a, b) => a.latestScanned.compareTo(b.latestScanned));
case 'a-z':
result.sort(
(a, b) =>
a.albumName.toLowerCase().compareTo(b.albumName.toLowerCase()),
);
case 'z-a':
result.sort(
(a, b) =>
b.albumName.toLowerCase().compareTo(a.albumName.toLowerCase()),
);
default: // 'latest' - already sorted
break;
}
return result;
}
({List<_GroupedAlbum> albums, List<_GroupedLocalAlbum> localAlbums})
_resolveFilteredGroupedAlbums(_HistoryStats historyStats) {
final cacheValid =
identical(_groupedAlbumFilterHistoryStatsCache, historyStats) &&
_groupedAlbumFilterSearchQuery == _searchQuery &&
_groupedAlbumFilterSource == _filterSource &&
_groupedAlbumFilterQuality == _filterQuality &&
_groupedAlbumFilterFormat == _filterFormat &&
_groupedAlbumFilterSortMode == _sortMode;
if (cacheValid) {
return (
albums: _filteredGroupedAlbumsCache,
localAlbums: _filteredGroupedLocalAlbumsCache,
);
}
final filteredGroupedAlbums = _filterGroupedAlbums(
historyStats.groupedAlbums,
_searchQuery,
);
final filteredGroupedLocalAlbums = _filterGroupedLocalAlbums(
historyStats.groupedLocalAlbums,
_searchQuery,
);
_groupedAlbumFilterHistoryStatsCache = historyStats;
_groupedAlbumFilterSearchQuery = _searchQuery;
_groupedAlbumFilterSource = _filterSource;
_groupedAlbumFilterQuality = _filterQuality;
_groupedAlbumFilterFormat = _filterFormat;
_groupedAlbumFilterSortMode = _sortMode;
_filteredGroupedAlbumsCache = filteredGroupedAlbums;
_filteredGroupedLocalAlbumsCache = filteredGroupedLocalAlbums;
return (
albums: filteredGroupedAlbums,
localAlbums: filteredGroupedLocalAlbums,
);
}
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
final formats = <String>{};
for (final item in items) {
@@ -2210,6 +2060,135 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
_HistoryStats _buildHistoryStats(
List<DownloadHistoryItem> items, [
List<LocalLibraryItem> localItems = const [],
]) {
final albumCounts = <String, int>{};
final albumMap = <String, List<DownloadHistoryItem>>{};
for (final item in items) {
// Use lowercase key for case-insensitive grouping
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
albumCounts[key] = (albumCounts[key] ?? 0) + 1;
albumMap.putIfAbsent(key, () => []).add(item);
}
int singleTracks = 0;
for (final item in items) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
if ((albumCounts[key] ?? 0) <= 1) {
singleTracks++;
}
}
final groupedAlbums = <_GroupedAlbum>[];
albumMap.forEach((_, tracks) {
if (tracks.length <= 1) return;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
groupedAlbums.add(
_GroupedAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverUrl: tracks.first.coverUrl,
sampleFilePath: tracks.first.filePath,
tracks: tracks,
latestDownload: tracks
.map((t) => t.downloadedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
),
);
});
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
int albumCount = 0;
for (final count in albumCounts.values) {
if (count > 1) albumCount++;
}
final downloadedPathKeys = <String>{};
for (final item in items) {
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
}
final dedupedLocalItems = localItems
.where((item) {
final localPathKeys = buildPathMatchKeys(item.filePath);
return !localPathKeys.any(downloadedPathKeys.contains);
})
.toList(growable: false);
// Calculate local library stats
final localAlbumCounts = <String, int>{};
final localAlbumMap = <String, List<LocalLibraryItem>>{};
for (final item in dedupedLocalItems) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1;
localAlbumMap.putIfAbsent(key, () => []).add(item);
}
int localAlbumCount = 0;
int localSingleTracks = 0;
for (final count in localAlbumCounts.values) {
if (count > 1) {
localAlbumCount++;
} else {
localSingleTracks++;
}
}
// Build grouped local albums
final groupedLocalAlbums = <_GroupedLocalAlbum>[];
localAlbumMap.forEach((_, tracks) {
if (tracks.length <= 1) return;
tracks.sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
return aNum.compareTo(bNum);
});
groupedLocalAlbums.add(
_GroupedLocalAlbum(
albumName: tracks.first.albumName,
artistName: tracks.first.albumArtist ?? tracks.first.artistName,
coverPath: tracks
.firstWhere(
(t) => t.coverPath != null && t.coverPath!.isNotEmpty,
orElse: () => tracks.first,
)
.coverPath,
tracks: tracks,
latestScanned: tracks
.map((t) => t.scannedAt)
.reduce((a, b) => a.isAfter(b) ? a : b),
),
);
});
groupedLocalAlbums.sort(
(a, b) => b.latestScanned.compareTo(a.latestScanned),
);
return _HistoryStats(
albumCounts: albumCounts,
localAlbumCounts: localAlbumCounts,
groupedAlbums: groupedAlbums,
groupedLocalAlbums: groupedLocalAlbums,
albumCount: albumCount,
singleTracks: singleTracks,
localAlbumCount: localAlbumCount,
localSingleTracks: localSingleTracks,
);
}
void _navigateToDownloadedAlbum(_GroupedAlbum album) {
_searchFocusNode.unfocus();
Navigator.push(
@@ -2563,19 +2542,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
? ref.watch(localLibraryProvider.select((s) => s.items))
: const <LocalLibraryItem>[];
final collectionState = ref.watch(libraryCollectionsProvider);
final historyStats = ref.watch(_queueHistoryStatsProvider);
final filteredGrouped = ref.watch(
_queueFilteredAlbumsProvider(
_QueueGroupedAlbumFilterRequest(
searchQuery: _searchQuery,
filterSource: _filterSource,
filterQuality: _filterQuality,
filterFormat: _filterFormat,
sortMode: _sortMode,
),
),
);
_ensureHistoryCaches(allHistoryItems, localLibraryItems, historyStats);
_ensureHistoryCaches(allHistoryItems, localLibraryItems);
final historyViewMode = ref.watch(
settingsProvider.select((s) => s.historyViewMode),
);
@@ -2584,6 +2552,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final historyStats =
_historyStatsCache ??
_buildHistoryStats(allHistoryItems, localLibraryItems);
final filteredGrouped = _resolveFilteredGroupedAlbums(historyStats);
final filteredGroupedAlbums = filteredGrouped.albums;
final filteredGroupedLocalAlbums = filteredGrouped.localAlbums;
final albumCount = historyStats.totalAlbumCount;
+1 -2
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
@@ -53,7 +52,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
@override
+2 -2
View File
@@ -234,7 +234,7 @@ class AboutPage extends StatelessWidget {
icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle:
'v${AppInfo.displayVersion} (build ${AppInfo.buildNumber})',
'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
@@ -341,7 +341,7 @@ class _AppHeaderCard extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.displayVersion}',
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
@@ -56,7 +56,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
setState(() => _isLoading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
@@ -282,7 +282,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
).showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
if (mounted) {
setState(() => _busyAction = null);
@@ -394,7 +394,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
),
actions: [
IconButton(
tooltip: context.l10n.cacheRefresh,
tooltip: 'Refresh',
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
),
@@ -376,7 +376,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
title: context.l10n.downloadAskBeforeDownload,
subtitle: isBuiltInService
? context.l10n.downloadAskQualitySubtitle
: context.l10n.downloadSelectServiceToEnable,
: 'Select a built-in service to enable',
value: settings.askQualityBeforeDownload,
enabled: isBuiltInService,
onChanged: (value) => ref
@@ -413,7 +413,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
if (isTidalService)
_QualityOption(
title: context.l10n.downloadLossy320,
title: 'Lossy 320kbps',
subtitle: _getTidalHighFormatLabel(
settings.tidalHighFormat,
),
@@ -426,7 +426,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (isTidalService && settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: context.l10n.downloadLossyFormat,
title: 'Lossy Format',
subtitle: _getTidalHighFormatLabel(
settings.tidalHighFormat,
),
@@ -451,7 +451,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.downloadSelectTidalQobuz,
'Select Tidal or Qobuz above to configure quality',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -504,7 +504,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
title: context.l10n.optionsEmbedLyrics,
subtitle: settings.embedMetadata
? context.l10n.optionsEmbedLyricsSubtitle
: context.l10n.downloadEmbedLyricsDisabled,
: 'Disabled while Embed Metadata is turned off',
value: settings.embedLyrics,
enabled: settings.embedMetadata,
onChanged: (value) => ref
@@ -528,7 +528,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsItem(
icon: Icons.source_outlined,
title: context.l10n.lyricsProvidersTitle,
title: 'Lyrics Providers',
subtitle: _getLyricsProvidersSubtitle(
settings.lyricsProviders,
),
@@ -541,10 +541,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsSwitchItem(
icon: Icons.translate_outlined,
title: context.l10n.downloadNeteaseIncludeTranslation,
title: 'Netease: Include Translation',
subtitle: settings.lyricsIncludeTranslationNetease
? context.l10n.downloadNeteaseIncludeTranslationEnabled
: context.l10n.downloadNeteaseIncludeTranslationDisabled,
? 'Append translated lyrics when available'
: 'Use original lyrics only',
value: settings.lyricsIncludeTranslationNetease,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -552,10 +552,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsSwitchItem(
icon: Icons.text_fields_outlined,
title: context.l10n.downloadNeteaseIncludeRomanization,
title: 'Netease: Include Romanization',
subtitle: settings.lyricsIncludeRomanizationNetease
? context.l10n.downloadNeteaseIncludeRomanizationEnabled
: context.l10n.downloadNeteaseIncludeRomanizationDisabled,
? 'Append romanized lyrics when available'
: 'Disabled',
value: settings.lyricsIncludeRomanizationNetease,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -563,10 +563,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsSwitchItem(
icon: Icons.record_voice_over_outlined,
title: context.l10n.downloadAppleQqMultiPerson,
title: 'Apple/QQ Multi-Person Word-by-Word',
subtitle: settings.lyricsMultiPersonWordByWord
? context.l10n.downloadAppleQqMultiPersonEnabled
: context.l10n.downloadAppleQqMultiPersonDisabled,
? 'Enable v1/v2 speaker and [bg:] tags'
: 'Simplified word-by-word formatting',
value: settings.lyricsMultiPersonWordByWord,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -574,9 +574,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsItem(
icon: Icons.language_outlined,
title: context.l10n.downloadMusixmatchLanguage,
title: 'Musixmatch Language',
subtitle: settings.musixmatchLanguage.isEmpty
? context.l10n.downloadMusixmatchLanguageAuto
? 'Auto (original)'
: settings.musixmatchLanguage.toUpperCase(),
onTap: () => _showMusixmatchLanguagePicker(
context,
@@ -622,8 +622,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
icon: Icons.library_music_outlined,
title: context.l10n.downloadSeparateSinglesFolder,
subtitle: settings.separateSingles
? context.l10n.downloadSeparateSinglesEnabled
: context.l10n.downloadSeparateSinglesDisabled,
? 'Albums/ and Singles/ folders'
: 'All files in same structure',
value: settings.separateSingles,
onChanged: (value) => ref
.read(settingsProvider.notifier)
@@ -670,9 +670,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
),
SettingsItem(
SettingsItem(
icon: Icons.filter_alt_outlined,
title: context.l10n.downloadArtistNameFilters,
title: 'Artist Name Filters',
subtitle: _getArtistFolderFilterSubtitle(
context,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
@@ -707,16 +707,28 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.group_remove_outlined,
title: context.l10n.downloadFilterContributing,
title: 'Filter contributing artists in Album Artist',
subtitle: settings.filterContributingArtistsInAlbumArtist
? context.l10n.downloadFilterContributingEnabled
: context.l10n.downloadFilterContributingDisabled,
? 'Album Artist metadata uses primary artist only'
: 'Keep full Album Artist metadata value',
value: settings.filterContributingArtistsInAlbumArtist,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setFilterContributingArtistsInAlbumArtist(value),
showDivider: false,
),
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
showDivider: false,
),
],
),
),
@@ -741,7 +753,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsItem(
icon: Icons.public,
title: context.l10n.downloadSongLinkRegion,
title: 'SongLink Region',
subtitle: _getSongLinkRegionLabel(settings.songLinkRegion),
onTap: () => _showSongLinkRegionPicker(
context,
@@ -751,10 +763,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SettingsSwitchItem(
icon: Icons.security_outlined,
title: context.l10n.downloadNetworkCompatibilityMode,
title: 'Network compatibility mode',
subtitle: settings.networkCompatibilityMode
? context.l10n.downloadNetworkCompatibilityModeEnabled
: context.l10n.downloadNetworkCompatibilityModeDisabled,
? 'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'
: 'Off: strict HTTPS certificate validation (recommended)',
value: settings.networkCompatibilityMode,
onChanged: (value) {
ref
@@ -1033,7 +1045,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
const SizedBox(height: 8),
Text(
context.l10n.downloadFilenameDescription,
'Customize how your files are named.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1058,7 +1070,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(height: 24),
Text(
context.l10n.downloadFilenameInsertTag,
'Tap to insert tag:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -1226,7 +1238,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.setupDownloadLocationTitle,
'Download Location',
style: Theme.of(
ctx,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -1235,7 +1247,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadLocationSubtitle,
'Choose storage mode for downloaded files.',
style: Theme.of(ctx).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1243,8 +1255,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: Text(context.l10n.storageModeAppFolder),
subtitle: Text(context.l10n.storageModeAppFolderSubtitle),
title: const Text('App folder (non-SAF)'),
subtitle: const Text('Use default Music/SpotiFLAC path'),
trailing: !isSafMode ? const Icon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
@@ -1257,8 +1269,10 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
ListTile(
leading: Icon(Icons.folder_open, color: colorScheme.primary),
title: Text(context.l10n.storageModeSaf),
subtitle: Text(context.l10n.storageModeSafSubtitle),
title: const Text('SAF folder'),
subtitle: const Text(
'Pick folder via Android Storage Access Framework',
),
trailing: isSafMode ? const Icon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
@@ -1532,7 +1546,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
};
String _getLyricsProvidersSubtitle(List<String> providers) {
if (providers.isEmpty) return context.l10n.downloadProvidersNoneEnabled;
if (providers.isEmpty) return 'None enabled';
return providers.map((p) => _providerDisplayNames[p] ?? p).join(' > ');
}
@@ -1631,14 +1645,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadMusixmatchLanguage,
'Musixmatch Language',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
context.l10n.downloadMusixmatchLanguageDesc,
'Set preferred language code (example: en, es, ja). Leave empty for auto.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1647,9 +1661,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
TextField(
controller: controller,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: context.l10n.downloadMusixmatchLanguageCode,
hintText: context.l10n.downloadMusixmatchLanguageHint,
decoration: const InputDecoration(
labelText: 'Language code',
hintText: 'auto / en / es / ja',
),
),
const SizedBox(height: 16),
@@ -1668,7 +1682,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.setMusixmatchLanguage('');
Navigator.pop(context);
},
child: Text(context.l10n.downloadMusixmatchAuto),
child: const Text('Auto'),
),
const SizedBox(width: 8),
FilledButton(
@@ -1725,7 +1739,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadLossy320Format,
'Lossy 320kbps Format',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -1734,7 +1748,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadLossy320FormatDesc,
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1742,8 +1756,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: Text(context.l10n.downloadLossyMp3),
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: current == 'mp3_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
@@ -1756,8 +1770,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus256),
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
title: const Text('Opus 256kbps'),
subtitle: const Text('Best quality Opus, ~8MB per track'),
trailing: current == 'opus_256'
? Icon(Icons.check, color: colorScheme.primary)
: null,
@@ -1770,8 +1784,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus128),
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
title: const Text('Opus 128kbps'),
subtitle: const Text('Smallest size, ~4MB per track'),
trailing: current == 'opus_128'
? Icon(Icons.check, color: colorScheme.primary)
: null,
@@ -1828,7 +1842,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ListTile(
leading: const Icon(Icons.signal_cellular_alt),
title: Text(context.l10n.settingsDownloadNetworkAny),
subtitle: Text(context.l10n.downloadNetworkAnySubtitle),
subtitle: const Text('WiFi + Mobile Data'),
trailing: current == 'any'
? Icon(Icons.check, color: colorScheme.primary)
: null,
@@ -1842,7 +1856,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ListTile(
leading: const Icon(Icons.wifi),
title: Text(context.l10n.settingsDownloadNetworkWifiOnly),
subtitle: Text(context.l10n.downloadNetworkWifiOnlySubtitle),
subtitle: const Text('Pause downloads on mobile data'),
trailing: current == 'wifi_only'
? Icon(Icons.check, color: colorScheme.primary)
: null,
@@ -1883,17 +1897,17 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadSongLinkRegion,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
child: Text(
'SongLink Region',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadSongLinkRegionDesc,
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Used as userCountry for SongLink API lookup.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -1954,12 +1968,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadFolderOrganization,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
child: Text(
'Folder Organization',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
@@ -801,7 +801,7 @@ class _SettingItemState extends State<_SettingItem> {
Future<void> _invokeAction(BuildContext context) async {
if (widget.setting.action == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarNoActionDefined)),
const SnackBar(content: Text('No action defined for this button')),
);
return;
}
@@ -834,7 +834,7 @@ class _SettingItemState extends State<_SettingItem> {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (mounted) {
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -56,18 +55,18 @@ class _LyricsProviderPriorityPageState
return PrioritySettingsScaffold(
hasChanges: _hasChanges,
title: context.l10n.lyricsProvidersTitle,
description: context.l10n.lyricsProvidersDescription,
infoText: context.l10n.lyricsProvidersInfoText,
title: 'Lyrics Providers',
description:
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.',
infoText:
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.',
onSave: _saveChanges,
onConfirmDiscard: _confirmDiscard,
slivers: [
if (_enabledProviders.isNotEmpty)
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.lyricsProvidersEnabledSection(
_enabledProviders.length,
),
title: 'Enabled (${_enabledProviders.length})',
),
),
if (_enabledProviders.isNotEmpty)
@@ -77,7 +76,7 @@ class _LyricsProviderPriorityPageState
itemCount: _enabledProviders.length,
itemBuilder: (context, index) {
final id = _enabledProviders[index];
final info = _getLyricsProviderInfo(id, context);
final info = _getLyricsProviderInfo(id);
return _EnabledProviderItem(
key: ValueKey(id),
providerId: id,
@@ -100,9 +99,7 @@ class _LyricsProviderPriorityPageState
if (disabled.isNotEmpty)
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.lyricsProvidersDisabledSection(
disabled.length,
),
title: 'Disabled (${disabled.length})',
),
),
if (disabled.isNotEmpty)
@@ -111,7 +108,7 @@ class _LyricsProviderPriorityPageState
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final id = disabled[index];
final info = _getLyricsProviderInfo(id, context);
final info = _getLyricsProviderInfo(id);
return _DisabledProviderItem(
key: ValueKey(id),
providerId: id,
@@ -133,8 +130,8 @@ class _LyricsProviderPriorityPageState
void _disableProvider(String id) {
if (_enabledProviders.length <= 1) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.lyricsProvidersAtLeastOne),
const SnackBar(
content: Text('At least one provider must remain enabled'),
),
);
return;
@@ -153,7 +150,7 @@ class _LyricsProviderPriorityPageState
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.lyricsProvidersSaved)),
const SnackBar(content: Text('Lyrics provider priority saved')),
);
}
}
@@ -162,16 +159,16 @@ class _LyricsProviderPriorityPageState
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogDiscardChanges),
content: Text(context.l10n.lyricsProvidersDiscardContent),
title: const Text('Discard changes?'),
content: const Text('You have unsaved changes that will be lost.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogDiscard),
child: const Text('Discard'),
),
],
),
@@ -179,51 +176,48 @@ class _LyricsProviderPriorityPageState
return result ?? false;
}
static _LyricsProviderInfo _getLyricsProviderInfo(
String id,
BuildContext context,
) {
static _LyricsProviderInfo _getLyricsProviderInfo(String id) {
switch (id) {
case 'spotify_api':
return _LyricsProviderInfo(
name: 'Spotify Lyrics API',
description: context.l10n.lyricsProviderSpotifyApiDesc,
description: 'Spotify-sourced synced lyrics via community API',
icon: Icons.music_note_outlined,
);
case 'lrclib':
return _LyricsProviderInfo(
name: 'LRCLIB',
description: context.l10n.lyricsProviderLrclibDesc,
description: 'Open-source synced lyrics database',
icon: Icons.subtitles_outlined,
);
case 'netease':
return _LyricsProviderInfo(
name: 'Netease',
description: context.l10n.lyricsProviderNeteaseDesc,
description: 'NetEase Cloud Music (good for Asian songs)',
icon: Icons.cloud_outlined,
);
case 'musixmatch':
return _LyricsProviderInfo(
name: 'Musixmatch',
description: context.l10n.lyricsProviderMusixmatchDesc,
description: 'Largest lyrics database (multi-language)',
icon: Icons.translate,
);
case 'apple_music':
return _LyricsProviderInfo(
name: 'Apple Music',
description: context.l10n.lyricsProviderAppleMusicDesc,
description: 'Word-by-word synced lyrics (via proxy)',
icon: Icons.music_note,
);
case 'qqmusic':
return _LyricsProviderInfo(
name: 'QQ Music',
description: context.l10n.lyricsProviderQqMusicDesc,
description: 'QQ Music (good for Chinese songs, via proxy)',
icon: Icons.queue_music,
);
default:
return _LyricsProviderInfo(
name: id,
description: context.l10n.lyricsProviderExtensionDesc,
description: 'Extension provider',
icon: Icons.extension,
);
}
@@ -228,20 +228,6 @@ class _MetadataProviderItem extends StatelessWidget {
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'qobuz':
return _MetadataProviderInfo(
name: 'Qobuz',
icon: Icons.library_music,
description: context.l10n.providerBuiltIn,
isBuiltIn: true,
);
case 'tidal':
return _MetadataProviderInfo(
name: 'Tidal',
icon: Icons.music_note,
description: context.l10n.providerBuiltIn,
isBuiltIn: true,
);
default:
return _MetadataProviderInfo(
name: provider,
@@ -334,12 +334,6 @@ class _ProviderItem extends StatelessWidget {
);
case 'qobuz':
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
case 'deezer':
return _ProviderInfo(
name: 'Deezer',
icon: Icons.graphic_eq,
isBuiltIn: true,
);
case 'youtube':
return _ProviderInfo(
name: 'YouTube',
+3 -3
View File
@@ -107,8 +107,8 @@ class SettingsTab extends ConsumerWidget {
),
SettingsItem(
icon: Icons.favorite_outline,
title: l10n.settingsDonate,
subtitle: l10n.settingsDonateSubtitle,
title: 'Donate',
subtitle: 'Support SpotiFLAC-Mobile development',
onTap: () => _navigateTo(context, const DonatePage()),
showDivider: false,
),
@@ -133,7 +133,7 @@ class SettingsTab extends ConsumerWidget {
SettingsItem(
icon: Icons.info_outline,
title: l10n.settingsAbout,
subtitle: '${l10n.aboutVersion} ${AppInfo.displayVersion}',
subtitle: '${l10n.aboutVersion} ${AppInfo.version}',
onTap: () => _navigateTo(context, const AboutPage()),
showDivider: false,
),
+150 -328
View File
@@ -16,7 +16,6 @@ class StoreTab extends ConsumerStatefulWidget {
class _StoreTabState extends ConsumerState<StoreTab> {
final _searchController = TextEditingController();
final _repoUrlController = TextEditingController();
bool _isInitialized = false;
@override
@@ -39,7 +38,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
@override
void dispose() {
_searchController.dispose();
_repoUrlController.dispose();
super.dispose();
}
@@ -58,8 +56,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
final downloadingId = ref.watch(
storeProvider.select((s) => s.downloadingId),
);
final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl));
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
final filteredExtensions = StoreState(
extensions: extensions,
selectedCategory: selectedCategory,
@@ -88,14 +84,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
automaticallyImplyLeading: false,
actions: [
if (hasRegistryUrl)
IconButton(
icon: const Icon(Icons.link),
tooltip: context.l10n.storeChangeRepoTooltip,
onPressed: () => _showChangeRepoDialog(registryUrl),
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
@@ -121,175 +109,169 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
),
if (!hasRegistryUrl)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (context, value, _) {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref
.read(storeProvider.notifier)
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
filled: true,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
},
);
},
),
),
),
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
_CategoryChip(
label: context.l10n.storeFilterAll,
icon: Icons.apps,
isSelected: selectedCategory == null,
onTap: () =>
ref.read(storeProvider.notifier).setCategory(null),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterMetadata,
icon: Icons.label_outline,
isSelected: selectedCategory == StoreCategory.metadata,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.metadata),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterDownload,
icon: Icons.download_outlined,
isSelected: selectedCategory == StoreCategory.download,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.download),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterUtility,
icon: Icons.build_outlined,
isSelected: selectedCategory == StoreCategory.utility,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.utility),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterLyrics,
icon: Icons.lyrics_outlined,
isSelected: selectedCategory == StoreCategory.lyrics,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.lyrics),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected: selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
),
],
),
),
),
if (isLoading && extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
else if (filteredExtensions.isEmpty)
SliverFillRemaining(
child: _buildSetupRepoState(colorScheme, error),
child: _buildEmptyState(
hasFilters:
searchQuery.isNotEmpty || selectedCategory != null,
colorScheme: colorScheme,
),
)
else ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _searchController,
builder: (context, value, _) {
return TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: context.l10n.storeSearch,
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref
.read(storeProvider.notifier)
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
filled: true,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value);
},
);
},
child: Text(
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
SliverToBoxAdapter(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
_CategoryChip(
label: context.l10n.storeFilterAll,
icon: Icons.apps,
isSelected: selectedCategory == null,
onTap: () =>
ref.read(storeProvider.notifier).setCategory(null),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterMetadata,
icon: Icons.label_outline,
isSelected: selectedCategory == StoreCategory.metadata,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.metadata),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterDownload,
icon: Icons.download_outlined,
isSelected: selectedCategory == StoreCategory.download,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.download),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterUtility,
icon: Icons.build_outlined,
isSelected: selectedCategory == StoreCategory.utility,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.utility),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterLyrics,
icon: Icons.lyrics_outlined,
isSelected: selectedCategory == StoreCategory.lyrics,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.lyrics),
),
const SizedBox(width: 8),
_CategoryChip(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected: selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SettingsGroup(
children: filteredExtensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider: index < filteredExtensions.length - 1,
isDownloading: downloadingId == ext.id,
onInstall: () => _installExtension(ext),
onUpdate: () => _updateExtension(ext),
onTap: () => _showExtensionDetails(ext),
);
}).toList(),
),
),
),
if (isLoading && extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
else if (filteredExtensions.isEmpty)
SliverFillRemaining(
child: _buildEmptyState(
hasFilters:
searchQuery.isNotEmpty || selectedCategory != null,
colorScheme: colorScheme,
),
)
else ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'${filteredExtensions.length} ${filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SettingsGroup(
children: filteredExtensions.asMap().entries.map((entry) {
final index = entry.key;
final ext = entry.value;
return _ExtensionItem(
extension: ext,
showDivider: index < filteredExtensions.length - 1,
isDownloading: downloadingId == ext.id,
onInstall: () => _installExtension(ext),
onUpdate: () => _updateExtension(ext),
onTap: () => _showExtensionDetails(ext),
);
}).toList(),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
],
),
@@ -297,166 +279,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
);
}
Widget _buildSetupRepoState(ColorScheme colorScheme, String? error) {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.store_outlined,
size: 72,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 24),
Text(
context.l10n.storeAddRepoTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextField(
controller: _repoUrlController,
decoration: InputDecoration(
hintText: context.l10n.storeRepoUrlHint,
labelText: context.l10n.storeRepoUrlLabel,
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
),
keyboardType: TextInputType.url,
autocorrect: false,
onSubmitted: (_) => _submitRepoUrl(),
),
if (error != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
error,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _submitRepoUrl,
icon: const Icon(Icons.add),
label: Text(context.l10n.storeAddRepoButton),
),
),
],
),
),
);
}
void _submitRepoUrl() {
final url = _repoUrlController.text.trim();
if (url.isEmpty) return;
ref.read(storeProvider.notifier).setRegistryUrl(url);
}
void _showChangeRepoDialog(String currentUrl) {
final changeUrlController = TextEditingController(text: currentUrl);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.storeRepoDialogTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.storeRepoDialogCurrent,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
currentUrl,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
TextField(
controller: changeUrlController,
decoration: InputDecoration(
hintText: context.l10n.storeRepoUrlHint,
labelText: context.l10n.storeNewRepoUrlLabel,
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
keyboardType: TextInputType.url,
autocorrect: false,
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
ref.read(storeProvider.notifier).removeRegistryUrl();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogRemove),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () {
final newUrl = changeUrlController.text.trim();
Navigator.of(context).pop();
if (newUrl.isNotEmpty) {
ref.read(storeProvider.notifier).setRegistryUrl(newUrl);
}
},
child: Text(context.l10n.dialogSave),
),
],
),
).then((_) => changeUrlController.dispose());
}
Widget _buildErrorState(String error, ColorScheme colorScheme) {
return Center(
child: Padding(
@@ -467,7 +289,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Icon(Icons.error_outline, size: 64, color: colorScheme.error),
const SizedBox(height: 16),
Text(
context.l10n.storeLoadError,
'Failed to load store',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
@@ -506,7 +328,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(height: 16),
Text(
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions,
hasFilters ? 'No extensions found' : 'No extensions available',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
+80 -169
View File
@@ -64,9 +64,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? _lyricsError;
String? _lyricsSource;
bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false;
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
bool _isEmbedding = false; // Track embed operation in progress
bool _isInstrumental = false;
bool _isInstrumental = false; // Track if detected as instrumental
bool _isConverting = false; // Track convert operation in progress
bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false;
@@ -298,23 +298,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']);
final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']);
final resolvedDuration = _readPositiveInt(metadata['duration']);
final resolvedAlbum = metadata['album']?.toString();
final resolvedQuality = buildDisplayAudioQuality(
bitDepth: resolvedBitDepth ?? bitDepth,
sampleRate: resolvedSampleRate ?? sampleRate,
storedQuality: _quality,
);
// Fill in album name from file tags if stored value is empty
final needsAlbum = resolvedAlbum != null &&
resolvedAlbum.isNotEmpty &&
(albumName.isEmpty);
// Fill in duration from file if stored value is missing/zero
final needsDuration = resolvedDuration != null &&
resolvedDuration > 0 &&
(duration == null || duration == 0);
final shouldPersistResolvedAudioMetadata =
resolvedBitDepth != null ||
resolvedSampleRate != null ||
@@ -322,8 +310,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if ((resolvedBitDepth != null ||
resolvedSampleRate != null ||
needsAlbum ||
needsDuration ||
isPlaceholderQualityLabel(_quality)) &&
mounted) {
setState(() {
@@ -331,8 +317,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
...?_editedMetadata,
if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth,
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
if (needsAlbum) 'album': resolvedAlbum,
if (needsDuration) 'duration': resolvedDuration,
};
});
}
@@ -502,8 +486,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_editedMetadata?['copyright']?.toString() ??
(_isLocalItem ? null : _downloadItem!.copyright);
int? get duration =>
_readPositiveInt(_editedMetadata?['duration']) ??
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
int? get bitDepth =>
_readPositiveInt(_editedMetadata?['bit_depth']) ??
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
@@ -1052,23 +1035,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Builder(
builder: (context) {
final isDeezer = _spotifyId!.contains('deezer');
final svc = _service.toLowerCase();
String buttonLabel;
if (isDeezer) {
buttonLabel = context.l10n.trackOpenInDeezer;
} else if (svc == 'amazon') {
buttonLabel = 'Open in Amazon Music';
} else if (svc == 'tidal') {
buttonLabel = 'Open in Tidal';
} else if (svc == 'qobuz') {
buttonLabel = 'Open in Qobuz';
} else {
buttonLabel = context.l10n.trackOpenInSpotify;
}
return OutlinedButton.icon(
onPressed: () => _openServiceUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(buttonLabel),
label: Text(
isDeezer
? context.l10n.trackOpenInDeezer
: context.l10n.trackOpenInSpotify,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -1093,33 +1067,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final isDeezer = _spotifyId!.contains('deezer');
final rawId = _spotifyId!.replaceAll('deezer:', '');
final svc = _service.toLowerCase();
String webUrl;
Uri? appUri;
String serviceName;
final webUrl = isDeezer
? 'https://www.deezer.com/track/$rawId'
: 'https://open.spotify.com/track/$rawId';
if (isDeezer) {
webUrl = 'https://www.deezer.com/track/$rawId';
appUri = Uri.parse('deezer://www.deezer.com/track/$rawId');
serviceName = 'Deezer';
} else if (svc == 'amazon') {
webUrl = 'https://music.amazon.com/search/$rawId';
appUri = Uri.parse('amznm://search/$rawId');
serviceName = 'Amazon Music';
} else if (svc == 'tidal') {
webUrl = 'https://listen.tidal.com/track/$rawId';
appUri = Uri.parse('tidal://track/$rawId');
serviceName = 'Tidal';
} else if (svc == 'qobuz') {
webUrl = 'https://play.qobuz.com/track/$rawId';
appUri = Uri.parse('qobuz://track/$rawId');
serviceName = 'Qobuz';
} else {
webUrl = 'https://open.spotify.com/track/$rawId';
appUri = Uri.parse('spotify:track:$rawId');
serviceName = 'Spotify';
}
final appUri = isDeezer
? Uri.parse('deezer://www.deezer.com/track/$rawId')
: Uri.parse('spotify:track:$rawId');
try {
final launched = await launchUrl(
@@ -1145,7 +1100,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarUrlCopied(serviceName),
context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'),
),
),
);
@@ -1185,22 +1140,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) {
final isDeezer = _spotifyId!.contains('deezer');
final cleanId = _spotifyId!.replaceAll('deezer:', '');
String idLabel;
if (isDeezer) {
idLabel = 'Deezer ID';
} else {
switch (_service.toLowerCase()) {
case 'amazon':
idLabel = 'Amazon ASIN';
case 'tidal':
idLabel = 'Tidal ID';
case 'qobuz':
idLabel = 'Qobuz ID';
default:
idLabel = 'Spotify ID';
}
}
items.add(_MetadataItem(idLabel, cleanId));
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
}
items.add(
@@ -1213,12 +1153,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return Column(
children: items.map((metadata) {
final isCopyable =
metadata.label == 'ISRC' ||
metadata.label == 'Spotify ID' ||
metadata.label == 'Deezer ID' ||
metadata.label == 'Amazon ASIN' ||
metadata.label == 'Tidal ID' ||
metadata.label == 'Qobuz ID';
metadata.label == 'ISRC' || metadata.label == 'Spotify ID';
return InkWell(
onTap: isCopyable
? () => _copyToClipboard(context, metadata.value)
@@ -1661,6 +1596,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
try {
// Convert duration from seconds to milliseconds
final durationMs = (duration ?? 0) * 1000;
// First, check if lyrics are embedded in the file
@@ -1681,6 +1617,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final embeddedSource = embeddedResult['source']?.toString() ?? '';
if (embeddedLyrics.isNotEmpty) {
// Lyrics found in file
if (mounted) {
final cleanLyrics = _cleanLrcForDisplay(embeddedLyrics);
setState(() {
@@ -1713,6 +1650,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
lrcText == '[instrumental:true]';
if (mounted) {
// Check for instrumental marker
if (instrumental) {
setState(() {
_isInstrumental = true;
@@ -1793,13 +1731,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
success = ok;
if (!ok) {
error = context.l10n.snackbarFailedToWriteStorage;
error = 'Failed to write back to storage';
}
} else {
success = true;
}
} else {
error = result['error']?.toString() ?? context.l10n.snackbarFailedToEmbedLyrics;
error = result['error']?.toString() ?? 'Failed to embed lyrics';
}
} else if (isMp3 || isOpus) {
final metadata = _buildFallbackMetadata();
@@ -1845,7 +1783,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
if (ffmpegResult == null) {
error = context.l10n.snackbarFailedToEmbedLyrics;
error = 'Failed to embed lyrics';
} else if (_isSafFile) {
final ok = await PlatformBridge.writeTempToSaf(
ffmpegResult,
@@ -1853,13 +1791,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
success = ok;
if (!ok) {
error = context.l10n.snackbarFailedToWriteStorage;
error = 'Failed to write back to storage';
}
} else {
success = true;
}
} else {
error = context.l10n.snackbarUnsupportedAudioFormat;
error = 'Unsupported audio format';
}
if (mounted) {
@@ -1874,7 +1812,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} else {
setState(() => _isEmbedding = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics)),
SnackBar(content: Text(error ?? 'Failed to embed lyrics')),
);
}
}
@@ -1883,7 +1821,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
setState(() => _isEmbedding = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
} finally {
if (coverPath != null) {
@@ -2333,7 +2271,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
SnackBar(
content: Text(
context.l10n.trackSaveFailed(
context.l10n.snackbarFailedToWriteStorage,
'Failed to write back to storage',
),
),
),
@@ -2517,21 +2455,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
void _showOptionsMenu(
BuildContext screenContext,
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
showModalBottomSheet(
context: screenContext,
context: context,
useRootNavigator: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(screenContext).size.height * 0.7,
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
builder: (sheetContext) => SafeArea(
builder: (context) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -2548,99 +2486,89 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.copy),
title: Text(sheetContext.l10n.trackCopyFilePath),
title: Text(context.l10n.trackCopyFilePath),
onTap: () {
_closeOptionsMenuAndRun(
sheetContext,
() => _copyToClipboard(screenContext, cleanFilePath),
);
Navigator.pop(context);
_copyToClipboard(context, cleanFilePath);
},
),
if (_fileExists)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(sheetContext.l10n.trackEditMetadata),
title: Text(context.l10n.trackEditMetadata),
onTap: () {
_closeOptionsMenuAndRun(
sheetContext,
() =>
_showEditMetadataSheet(screenContext, ref, colorScheme),
);
Navigator.pop(context);
_showEditMetadataSheet(context, ref, colorScheme);
},
),
if (!_isLocalItem && (_coverUrl != null || _fileExists))
ListTile(
leading: const Icon(Icons.image_outlined),
title: Text(sheetContext.l10n.trackSaveCoverArt),
subtitle: Text(sheetContext.l10n.trackSaveCoverArtSubtitle),
title: Text(context.l10n.trackSaveCoverArt),
subtitle: Text(context.l10n.trackSaveCoverArtSubtitle),
onTap: () {
_closeOptionsMenuAndRun(sheetContext, _saveCoverArt);
Navigator.pop(context);
_saveCoverArt();
},
),
if (!_isLocalItem)
ListTile(
leading: const Icon(Icons.lyrics_outlined),
title: Text(sheetContext.l10n.trackSaveLyrics),
subtitle: Text(sheetContext.l10n.trackSaveLyricsSubtitle),
title: Text(context.l10n.trackSaveLyrics),
subtitle: Text(context.l10n.trackSaveLyricsSubtitle),
onTap: () {
_closeOptionsMenuAndRun(sheetContext, _saveLyrics);
Navigator.pop(context);
_saveLyrics();
},
),
if (_fileExists)
ListTile(
leading: const Icon(Icons.travel_explore),
title: Text(sheetContext.l10n.trackReEnrich),
subtitle: Text(sheetContext.l10n.trackReEnrichOnlineSubtitle),
title: Text(context.l10n.trackReEnrich),
subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle),
onTap: () {
_closeOptionsMenuAndRun(sheetContext, _reEnrichMetadata);
Navigator.pop(context);
_reEnrichMetadata();
},
),
if (_fileExists && _isConvertibleFormat)
ListTile(
leading: const Icon(Icons.swap_horiz),
title: Text(sheetContext.l10n.trackConvertFormat),
subtitle: Text(sheetContext.l10n.trackConvertFormatSubtitle),
title: Text(context.l10n.trackConvertFormat),
subtitle: Text(context.l10n.trackConvertFormatSubtitle),
onTap: () {
_closeOptionsMenuAndRun(
sheetContext,
() => _showConvertSheet(screenContext),
);
Navigator.pop(context);
_showConvertSheet(context);
},
),
if (_fileExists && _isCueFile)
ListTile(
leading: const Icon(Icons.call_split),
title: Text(sheetContext.l10n.cueSplitTitle),
subtitle: Text(sheetContext.l10n.cueSplitSubtitle),
title: Text(context.l10n.cueSplitTitle),
subtitle: Text(context.l10n.cueSplitSubtitle),
onTap: () {
_closeOptionsMenuAndRun(
sheetContext,
() => _showCueSplitSheet(screenContext),
);
Navigator.pop(context);
_showCueSplitSheet(context);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.share),
title: Text(sheetContext.l10n.trackMetadataShare),
title: Text(context.l10n.trackMetadataShare),
onTap: () {
_closeOptionsMenuAndRun(
sheetContext,
() => _shareFile(screenContext),
);
Navigator.pop(context);
_shareFile(context);
},
),
ListTile(
leading: Icon(Icons.delete, color: colorScheme.error),
title: Text(
sheetContext.l10n.trackRemoveFromDevice,
context.l10n.trackRemoveFromDevice,
style: TextStyle(color: colorScheme.error),
),
onTap: () {
_closeOptionsMenuAndRun(
sheetContext,
() => _confirmDelete(screenContext, ref, colorScheme),
);
Navigator.pop(context);
_confirmDelete(context, ref, colorScheme);
},
),
const SizedBox(height: 16),
@@ -2935,7 +2863,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
// Show loading indicator
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)),
const SnackBar(content: Text('Loading CUE sheet...')),
);
try {
@@ -3706,7 +3634,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (saved == true && mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)),
const SnackBar(content: Text('Metadata saved successfully')),
);
// Re-read metadata from file to refresh the display
try {
@@ -3722,20 +3650,20 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
void _confirmDelete(
BuildContext screenContext,
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
showDialog(
context: screenContext,
useRootNavigator: true,
builder: (dialogContext) => AlertDialog(
title: Text(dialogContext.l10n.trackDeleteConfirmTitle),
content: Text(dialogContext.l10n.trackDeleteConfirmMessage),
context: context,
useRootNavigator: false,
builder: (context) => AlertDialog(
title: Text(context.l10n.trackDeleteConfirmTitle),
content: Text(context.l10n.trackDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(dialogContext.l10n.dialogCancel),
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () async {
@@ -3763,19 +3691,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
.removeFromHistory(_downloadItem!.id);
}
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
if (context.mounted) {
Navigator.pop(context);
Navigator.pop(context);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop(true);
}
});
},
child: Text(
dialogContext.l10n.dialogDelete,
context.l10n.dialogDelete,
style: TextStyle(color: colorScheme.error),
),
),
@@ -3784,17 +3706,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
void _closeOptionsMenuAndRun(
BuildContext sheetContext,
VoidCallback action,
) {
Navigator.pop(sheetContext);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
action();
});
}
Future<void> _openFile(BuildContext context, String filePath) async {
if (isCueVirtualPath(filePath)) {
_showCueVirtualTrackSnackBar(context);
@@ -4128,7 +4039,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
).showSnackBar(SnackBar(content: Text('Failed to pick cover: $e')));
}
}
@@ -4352,7 +4263,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
).showSnackBar(SnackBar(content: Text('Failed to save metadata: $e')));
}
} finally {
if (mounted) setState(() => _saving = false);
+4
View File
@@ -1385,6 +1385,7 @@ class FFmpegService {
final track = tracks[i];
onProgress?.call(i + 1, tracks.length);
// Sanitize filename
final sanitizedTitle = track.title
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
.replaceAll(RegExp(r'\s+'), ' ')
@@ -1393,9 +1394,11 @@ class FFmpegService {
final outputFileName = '$trackNumStr - $sanitizedTitle.$outputExt';
final outputPath = '$outputDir${Platform.pathSeparator}$outputFileName';
// Build FFmpeg command for this track
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$audioPath" ');
// Time range
final startTime = _formatSecondsForFFmpeg(track.startSec);
cmdBuffer.write('-ss $startTime ');
@@ -1410,6 +1413,7 @@ class FFmpegService {
cmdBuffer.write('-c:a copy ');
}
// Metadata
final artist = track.artist.isNotEmpty ? track.artist : (albumMetadata['artist'] ?? '');
final album = albumMetadata['album'] ?? '';
final genre = albumMetadata['genre'] ?? '';
+3 -30
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
@@ -178,6 +176,7 @@ class LibraryDatabase {
_log.i('Upgrading library database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
// Add cover_path column
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
_log.i('Added cover_path column');
}
@@ -243,6 +242,8 @@ class LibraryDatabase {
};
}
// CRUD Operations
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.insert(
@@ -472,34 +473,6 @@ class LibraryDatabase {
return result;
}
/// Export file modification times to a compact line-based snapshot that
/// native code can read without receiving a large method-channel payload.
Future<String> writeFileModTimesSnapshot() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library',
);
final tempDir = await getTemporaryDirectory();
final file = File(
join(
tempDir.path,
'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv',
),
);
final buffer = StringBuffer();
for (final row in rows) {
final path = row['file_path'] as String?;
if (path == null || path.isEmpty) continue;
final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0;
buffer
..write(modTime)
..write('\t')
..writeln(path);
}
await file.writeAsString(buffer.toString(), flush: true);
return file.path;
}
/// Update file_mod_time for existing rows using file_path as key.
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
if (fileModTimes.isEmpty) return;
-93
View File
@@ -535,48 +535,11 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getQobuzMetadata(
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getQobuzMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getQobuzMetadata returned null for $resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseQobuzUrl(String url) async {
final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseTidalUrl(String url) async {
final result = await _channel.invokeMethod('parseTidalUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getTidalMetadata(
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getTidalMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getTidalMetadata returned null for $resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(
String tidalUrl,
) async {
@@ -816,22 +779,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<List<Map<String, dynamic>>> searchTracksWithMetadataProviders(
String query, {
int limit = 20,
bool includeExtensions = true,
}) async {
_log.d(
'searchTracksWithMetadataProviders: "$query", includeExtensions=$includeExtensions',
);
final result = await _channel.invokeMethod(
'searchTracksWithMetadataProviders',
{'query': query, 'limit': limit, 'include_extensions': includeExtensions},
);
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<void> cleanupExtensions() async {
_log.d('cleanupExtensions');
await _channel.invokeMethod('cleanupExtensions');
@@ -1090,17 +1037,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> scanLibraryFolderIncrementalFromSnapshot(
String folderPath,
String snapshotPath,
) async {
final result = await _channel.invokeMethod(
'scanLibraryFolderIncrementalFromSnapshot',
{'folder_path': folderPath, 'snapshot_path': snapshotPath},
);
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
_log.i('scanSafTree: $treeUri');
final result = await _channel.invokeMethod('scanSafTree', {
@@ -1126,17 +1062,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> scanSafTreeIncrementalFromSnapshot(
String treeUri,
String snapshotPath,
) async {
final result = await _channel.invokeMethod(
'scanSafTreeIncrementalFromSnapshot',
{'tree_uri': treeUri, 'snapshot_path': snapshotPath},
);
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get last-modified timestamps for a list of SAF file URIs.
/// Returns map uri -> modTime (unix millis), only for files that still exist.
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
@@ -1266,24 +1191,6 @@ class PlatformBridge {
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
}
static Future<void> setStoreRegistryUrl(String registryUrl) async {
_log.d('setStoreRegistryUrl: $registryUrl');
await _channel.invokeMethod('setStoreRegistryUrl', {
'registry_url': registryUrl,
});
}
static Future<String> getStoreRegistryUrl() async {
_log.d('getStoreRegistryUrl');
final result = await _channel.invokeMethod('getStoreRegistryUrl');
return result as String? ?? '';
}
static Future<void> clearStoreRegistryUrl() async {
_log.d('clearStoreRegistryUrl');
await _channel.invokeMethod('clearStoreRegistryUrl');
}
static Future<List<Map<String, dynamic>>> getStoreExtensions({
bool forceRefresh = false,
}) async {
+1 -1
View File
@@ -157,7 +157,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_VersionChip(version: AppInfo.displayVersion, label: context.l10n.updateCurrent, colorScheme: colorScheme),
_VersionChip(version: AppInfo.version, label: context.l10n.updateCurrent, colorScheme: colorScheme),
const SizedBox(width: 12),
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 12),
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none"
version: 3.8.0+106
version: 3.7.2+105
environment:
sdk: ^3.10.0