mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| 6ecb69feae | |||
| feff985439 | |||
| 2e8fe34824 | |||
| f58005f406 | |||
| 75abc03a4f | |||
| 84381d142a | |||
| f67f52eba9 | |||
| 3747ffff64 | |||
| ed47efed17 | |||
| c0d72e89d7 | |||
| a4313cfe0f | |||
| c7bef03ee3 | |||
| ce5a9e0cff | |||
| 859b823e77 | |||
| 7d8cf5f7ca | |||
| 4adaed8da0 | |||
| 554fe08fcd | |||
| b8af75bf6e | |||
| 35f2f119db | |||
| f36096e0ac | |||
| 1665e4cd57 | |||
| 42f0267277 | |||
| 82f59d32b9 | |||
| 941347b007 | |||
| 739c89569f | |||
| 18607597e9 | |||
| 7bb808cba5 | |||
| 78cd396847 | |||
| bb342c01e2 | |||
| 8a5dc0edfe | |||
| 8540da484f | |||
| 20f789f8e0 | |||
| 3e89326c95 | |||
| a7ea4de25a | |||
| aabfbf062e | |||
| 7b9ed3ec8e | |||
| 6dad66d62d | |||
| 31018230ee | |||
| 54ddc1f59c | |||
| c6856bd1a1 | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| d5381afcf9 | |||
| 134bf4375f | |||
| aa9854fc0a | |||
| 10bc29e347 | |||
| 733efce161 | |||
| ac9141f167 | |||
| d89850e8a9 | |||
| 5948e4f125 | |||
| 34d22f783c | |||
| c347b6999e | |||
| adc74741ce | |||
| 48f614359e | |||
| 16669d8b7a | |||
| f1eef47600 | |||
| fc1567d2c8 | |||
| fffce6039a | |||
| cbfa147a12 | |||
| 5b8c953ae6 | |||
| 37a4dc096b | |||
| b3808645fb | |||
| 24aa804bf2 | |||
| 941ffb2bb7 | |||
| 59737d6f2b | |||
| c8ad93ee9b | |||
| 8cb0c037c2 | |||
| e30b69397b | |||
| d6e837fd61 | |||
| 5c97d202b9 | |||
| 0f6cfa75bb | |||
| 91bd6d1572 | |||
| df77ae3986 | |||
| 3cd6d068a2 | |||
| dd05061829 | |||
| 8f6b99c550 | |||
| f54ee86591 | |||
| 42e0ec2663 | |||
| 0456a97b35 | |||
| 07c609cc3a | |||
| de5d26403f | |||
| 73c2d0efac | |||
| d3c1c440cc | |||
| 94195c636f | |||
| 9abf492362 | |||
| defc84c216 | |||
| 3c9ae39145 | |||
| 581f43f4c1 | |||
| 221d7e4829 | |||
| 706528f04b | |||
| f95a96dd1f | |||
| d85c16ce0f | |||
| 35afdf4be4 | |||
| eb5ed86019 | |||
| 0cfa6f56be | |||
| 5af88ead33 | |||
| 8ec63ee610 | |||
| c8247bf7a0 | |||
| 2f3270c7ff | |||
| 960d60f0bc |
@@ -344,9 +344,18 @@ 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
|
||||
cp /tmp/changelog.txt /tmp/release_body.txt
|
||||
# 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
|
||||
|
||||
# Append download section
|
||||
cat >> /tmp/release_body.txt << FOOTER
|
||||
@@ -384,6 +393,63 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-altstore:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, build-ios, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Update apps.json
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
|
||||
if [ -z "$IPA_FILE" ]; then
|
||||
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||
|
||||
if [ ! -f apps.json ]; then
|
||||
echo "WARNING: apps.json not found on main, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq --arg ver "$VERSION_NUM" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||
--argjson size "$IPA_SIZE" \
|
||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||
|
||||
echo "Updated apps.json:"
|
||||
cat apps.json
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add apps.json
|
||||
git diff --cached --quiet && echo "No changes to commit" || \
|
||||
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
@@ -424,7 +490,10 @@ 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/&/\&/g' | \
|
||||
|
||||
@@ -67,6 +67,7 @@ AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
nul
|
||||
network_requests.txt
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
@@ -76,3 +77,6 @@ flutter_*.log
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
+2
-2
@@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||
- New backend client for `spotify.afkarxyz.fun/api`
|
||||
- New backend client for `sp.afkarxyz.qzz.io/api`
|
||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||
- Includes heuristic detection of lyrics stored in Comment fields
|
||||
@@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||
|
||||
+17
-3
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
5. **Run the app**
|
||||
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||
```bash
|
||||
cd go_backend
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile init
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
cd ..
|
||||
```
|
||||
|
||||
7. **Run the app**
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
@@ -40,13 +40,14 @@ Extensions allow the community to add new music sources and features without wai
|
||||
|
||||
### Installing Extensions
|
||||
1. Go to **Store** tab in the app
|
||||
2. Browse and install extensions with one tap
|
||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
4. Configure extension settings if needed
|
||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
|
||||
|
||||
## Other project
|
||||
|
||||
@@ -55,6 +56,9 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why does the Store tab ask me to enter a URL?**
|
||||
A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||
|
||||
**Q: Why is my download failing with "Song not found"?**
|
||||
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
|
||||
|
||||
@@ -73,6 +77,11 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
**Q: Can I add SpotiFLAC to AltStore or SideStore?**
|
||||
A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
|
||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||
In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
|
||||
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
|
||||
@@ -80,6 +89,18 @@ _If this software is useful and brings you value, consider supporting the projec
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
|
||||
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile.
|
||||
|
||||
Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
## API Credits
|
||||
|
||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
@@ -87,4 +108,4 @@ _If this software is useful and brings you value, consider supporting the projec
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay
|
||||
|
||||
@@ -115,10 +115,8 @@ class DownloadService : Service() {
|
||||
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||
*/
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
// Log the timeout for debugging
|
||||
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||
|
||||
// Gracefully stop the service
|
||||
stopForegroundService()
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
@@ -38,7 +39,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"com.zarz.spotiflac/download_progress_stream"
|
||||
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
|
||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private val STREAM_POLLING_INTERVAL_MS = 800L
|
||||
private val STREAM_POLLING_INTERVAL_MS = 1200L
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
private val safScanLock = Any()
|
||||
@@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStableLibraryId(filePath: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
|
||||
val hex = bytes.joinToString("") { "%02x".format(it) }
|
||||
return "lib_$hex"
|
||||
}
|
||||
|
||||
data class SafScanProgress(
|
||||
var totalFiles: Int = 0,
|
||||
var scannedFiles: Int = 0,
|
||||
@@ -469,6 +477,32 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
lastLibraryScanProgressPayload = null
|
||||
}
|
||||
|
||||
private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
|
||||
if (snapshotPath.isBlank()) {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
val snapshotFile = File(snapshotPath)
|
||||
if (!snapshotFile.exists()) {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
val result = JSONObject()
|
||||
snapshotFile.forEachLine { line ->
|
||||
if (line.isBlank()) return@forEachLine
|
||||
val separatorIndex = line.indexOf('\t')
|
||||
if (separatorIndex <= 0 || separatorIndex >= line.length - 1) {
|
||||
return@forEachLine
|
||||
}
|
||||
val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
|
||||
val filePath = line.substring(separatorIndex + 1)
|
||||
if (filePath.isNotEmpty()) {
|
||||
result.put(filePath, modTime)
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
|
||||
val obj = JSONObject()
|
||||
if (treeUriStr.isBlank() || fileName.isBlank()) {
|
||||
@@ -703,6 +737,80 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildUriDisplayName(
|
||||
uri: Uri,
|
||||
displayNameHint: String? = null,
|
||||
fallbackExt: String? = null,
|
||||
): String {
|
||||
val explicitName = displayNameHint?.trim().orEmpty()
|
||||
if (explicitName.isNotEmpty()) return explicitName
|
||||
|
||||
val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
|
||||
val uriName = uri.lastPathSegment
|
||||
val resolvedName = (docName ?: uriName ?: "").trim()
|
||||
if (resolvedName.isNotEmpty()) return resolvedName
|
||||
|
||||
val ext = when {
|
||||
fallbackExt.isNullOrBlank().not() -> fallbackExt
|
||||
isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt)
|
||||
else -> ""
|
||||
}
|
||||
return if (ext.isNullOrBlank()) "audio" else "audio$ext"
|
||||
}
|
||||
|
||||
private fun readAudioMetadataFromUri(
|
||||
uri: Uri,
|
||||
displayNameHint: String? = null,
|
||||
fallbackExt: String? = null,
|
||||
): JSONObject? {
|
||||
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
|
||||
|
||||
try {
|
||||
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val directPath = "/proc/self/fd/${pfd.fd}"
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName)
|
||||
if (metadataJson.isNotBlank()) {
|
||||
val obj = JSONObject(metadataJson)
|
||||
if (!obj.has("error")) {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.d(
|
||||
"SpotiFLAC",
|
||||
"Direct SAF metadata read fallback for $uri: ${e.message}",
|
||||
)
|
||||
}
|
||||
|
||||
val tempPath = try {
|
||||
copyUriToTemp(uri, fallbackExt)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"SAF metadata fallback copy failed for $uri: ${e.message}",
|
||||
)
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
try {
|
||||
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName)
|
||||
if (metadataJson.isBlank()) return null
|
||||
val obj = JSONObject(metadataJson)
|
||||
return if (obj.has("error")) null else obj
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"SAF metadata temp read failed for $uri: ${e.message}",
|
||||
)
|
||||
return null
|
||||
} finally {
|
||||
try {
|
||||
File(tempPath).delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
|
||||
val srcFile = File(srcPath)
|
||||
if (!srcFile.exists()) return false
|
||||
@@ -873,6 +981,66 @@ 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 "[]"
|
||||
|
||||
@@ -891,6 +1059,7 @@ 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()
|
||||
@@ -987,7 +1156,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
var tempCuePath: String? = null
|
||||
var tempAudioPath: String? = null
|
||||
try {
|
||||
// Copy CUE to temp
|
||||
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
||||
if (tempCuePath == null) {
|
||||
errors++
|
||||
@@ -996,27 +1164,14 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the audio filename from the CUE sheet text
|
||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||
|
||||
// Find the referenced audio file as a sibling in the same SAF directory
|
||||
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
|
||||
}
|
||||
}
|
||||
val audioDoc = resolveCueAudioSibling(
|
||||
parentDir = parentDir,
|
||||
cueName = cueName,
|
||||
audioFileName = audioFileName,
|
||||
childLookupCache = safChildLookupCache,
|
||||
)
|
||||
|
||||
if (audioDoc == null) {
|
||||
android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
|
||||
@@ -1052,7 +1207,6 @@ 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,
|
||||
@@ -1111,35 +1265,19 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||
val tempPath = try {
|
||||
copyUriToTemp(doc.uri, fallbackExt)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"SAF scan: failed to copy ${doc.uri}: ${e.message}",
|
||||
)
|
||||
null
|
||||
}
|
||||
if (tempPath == null) {
|
||||
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
|
||||
if (metadataObj == null) {
|
||||
errors++
|
||||
} else {
|
||||
try {
|
||||
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
|
||||
if (metadataJson.isNotBlank()) {
|
||||
val obj = JSONObject(metadataJson)
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
obj.put("filePath", doc.uri.toString())
|
||||
obj.put("fileModTime", lastModified)
|
||||
results.put(obj)
|
||||
} else {
|
||||
errors++
|
||||
}
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", lastModified)
|
||||
results.put(metadataObj)
|
||||
} catch (_: Exception) {
|
||||
errors++
|
||||
} finally {
|
||||
try {
|
||||
File(tempPath).delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1214,6 +1352,7 @@ 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.
|
||||
@@ -1398,22 +1537,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||
|
||||
// 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
|
||||
audioDoc = try { parentDir.findFile(cueBaseName + ext.uppercase(Locale.ROOT)) } catch (_: Exception) { null }
|
||||
if (audioDoc != null) break
|
||||
}
|
||||
}
|
||||
val audioDoc = resolveCueAudioSibling(
|
||||
parentDir = parentDir,
|
||||
cueName = cueName,
|
||||
audioFileName = audioFileName,
|
||||
childLookupCache = safChildLookupCache,
|
||||
)
|
||||
|
||||
if (audioDoc == null) {
|
||||
android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
|
||||
@@ -1501,24 +1630,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
tempCue = copyUriToTemp(cueDoc.uri, ".cue")
|
||||
if (tempCue != null) {
|
||||
val audioFileName = extractCueAudioFileName(tempCue)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" }
|
||||
val audioDoc = resolveCueAudioSibling(
|
||||
parentDir = parentDir,
|
||||
cueName = cueName,
|
||||
audioFileName = audioFileName,
|
||||
childLookupCache = safChildLookupCache,
|
||||
)
|
||||
if (audioDoc != null) {
|
||||
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||
}
|
||||
@@ -1566,36 +1684,20 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
|
||||
val tempPath = try {
|
||||
copyUriToTemp(doc.uri, fallbackExt)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"SAF incremental scan: failed to copy ${doc.uri}: ${e.message}",
|
||||
)
|
||||
null
|
||||
}
|
||||
if (tempPath == null) {
|
||||
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt)
|
||||
if (metadataObj == null) {
|
||||
errors++
|
||||
} else {
|
||||
try {
|
||||
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
|
||||
if (metadataJson.isNotBlank()) {
|
||||
val obj = JSONObject(metadataJson)
|
||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||
obj.put("filePath", doc.uri.toString())
|
||||
obj.put("fileModTime", safeLastModified)
|
||||
obj.put("lastModified", safeLastModified)
|
||||
results.put(obj)
|
||||
} else {
|
||||
errors++
|
||||
}
|
||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", safeLastModified)
|
||||
metadataObj.put("lastModified", safeLastModified)
|
||||
results.put(metadataObj)
|
||||
} catch (_: Exception) {
|
||||
errors++
|
||||
} finally {
|
||||
try {
|
||||
File(tempPath).delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2540,6 +2642,28 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Tidal search API
|
||||
"searchTidalAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Qobuz search API
|
||||
"searchQobuzAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
@@ -2556,6 +2680,22 @@ 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) {
|
||||
@@ -2563,6 +2703,13 @@ 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) {
|
||||
@@ -2791,6 +2938,15 @@ 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") ?: "{}"
|
||||
@@ -2996,6 +3152,25 @@ 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) {
|
||||
@@ -3071,6 +3246,18 @@ 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) {
|
||||
@@ -3086,6 +3273,16 @@ 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) {
|
||||
@@ -3116,13 +3313,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
try {
|
||||
if (filePath.startsWith("content://")) {
|
||||
val uri = Uri.parse(filePath)
|
||||
val tempPath = copyUriToTemp(uri)
|
||||
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||
try {
|
||||
Gobackend.readAudioMetadataJSON(tempPath)
|
||||
} finally {
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
}
|
||||
val metadata = readAudioMetadataFromUri(uri)
|
||||
?: return@withContext """{"error":"Failed to read SAF audio metadata"}"""
|
||||
metadata.put("filePath", filePath)
|
||||
metadata.toString()
|
||||
} else {
|
||||
Gobackend.readAudioMetadataJSON(filePath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "SpotiFLAC Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "3.8.6",
|
||||
"versionDate": "2026-03-16",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-ios-unsigned.ipa",
|
||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 33676960
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-3
@@ -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 %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||
{%- endfor %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -58,8 +58,6 @@ 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)
|
||||
]
|
||||
|
||||
@@ -1566,7 +1566,14 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
||||
}
|
||||
|
||||
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
return extractAnyCoverArtWithHint(filePath, "")
|
||||
}
|
||||
|
||||
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext == "" {
|
||||
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
@@ -1587,7 +1594,19 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
return extractOggCoverArt(filePath)
|
||||
|
||||
case ".m4a":
|
||||
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
|
||||
data, err := extractCoverFromM4A(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
mimeType := "image/jpeg"
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4E &&
|
||||
data[3] == 0x47 {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||
@@ -1595,6 +1614,10 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
}
|
||||
|
||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||
cacheKey := filePath
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||
@@ -1611,7 +1634,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return pngPath, nil
|
||||
}
|
||||
|
||||
imageData, mimeType, err := extractAnyCoverArt(filePath)
|
||||
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// Replace any size pattern with 1800x1800
|
||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||
|
||||
+27
-14
@@ -114,7 +114,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// PERFORMER
|
||||
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||
value := unquoteCue(line[len("PERFORMER "):])
|
||||
if currentTrack != nil {
|
||||
@@ -125,7 +124,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// TITLE
|
||||
if strings.HasPrefix(upper, "TITLE ") {
|
||||
value := unquoteCue(line[len("TITLE "):])
|
||||
if currentTrack != nil {
|
||||
@@ -136,7 +134,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// FILE
|
||||
if strings.HasPrefix(upper, "FILE ") {
|
||||
rest := line[len("FILE "):]
|
||||
// Extract filename and type
|
||||
@@ -148,7 +145,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// TRACK
|
||||
if strings.HasPrefix(upper, "TRACK ") {
|
||||
// Save previous track
|
||||
if currentTrack != nil {
|
||||
@@ -168,7 +164,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// INDEX
|
||||
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 {
|
||||
@@ -184,7 +179,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ISRC
|
||||
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||
continue
|
||||
@@ -430,7 +424,15 @@ 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) {
|
||||
return scanCueFileForLibraryInternal(cuePath, "", "", 0, scanTime)
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime)
|
||||
}
|
||||
|
||||
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||
@@ -441,23 +443,35 @@ 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)
|
||||
}
|
||||
|
||||
// Resolve audio file — optionally in an overridden directory
|
||||
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||
if sheet == nil {
|
||||
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
resolveBase := cuePath
|
||||
if audioDir != "" {
|
||||
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||
if audioPath == "" {
|
||||
return nil, fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
}
|
||||
return audioPath, nil
|
||||
}
|
||||
|
||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
|
||||
// Try to get quality info from the audio file
|
||||
@@ -540,7 +554,6 @@ func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string,
|
||||
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
|
||||
|
||||
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Genres struct {
|
||||
Data []deezerGenre `json:"data"`
|
||||
} `json:"genres"`
|
||||
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
}
|
||||
|
||||
type AlbumExtendedMetadata struct {
|
||||
Genre string
|
||||
Label string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||
@@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
}
|
||||
|
||||
result := &AlbumExtendedMetadata{
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Copyright: album.Copyright,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
}
|
||||
}
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||
if err := verifyDeezerTrack(req, deezerID); err != nil {
|
||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||
}
|
||||
return trackURL, nil
|
||||
}
|
||||
|
||||
// Try resolving Deezer ID from Spotify ID via SongLink
|
||||
// Try SongLink
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
return availability.DeezerURL, nil
|
||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
// Fall through to ISRC search instead of using wrong track.
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try resolving from ISRC
|
||||
// Try ISRC
|
||||
isrc := strings.TrimSpace(req.ISRC)
|
||||
if isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
if err == nil && track != nil {
|
||||
deezerID = songLinkExtractDeezerTrackID(track)
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return nil // Can't verify — don't block the download.
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: trackResp.Track.Name,
|
||||
ArtistName: trackResp.Track.Artists,
|
||||
Duration: trackResp.Track.DurationMS / 1000,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
@@ -394,11 +433,6 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -461,6 +495,17 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
}
|
||||
|
||||
if downloadErr != nil || deezerURLErr != nil {
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
if deezerURLErr != nil {
|
||||
return DeezerDownloadResult{}, fmt.Errorf(
|
||||
"deezer download failed: direct Deezer resolution error: %v; Yoinkify fallback error: %w",
|
||||
deezerURLErr,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
|
||||
@@ -34,7 +34,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Slow path: need to build index
|
||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||
mu := buildLock.(*sync.Mutex)
|
||||
|
||||
+398
-100
@@ -128,6 +128,7 @@ type DownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
@@ -135,6 +136,36 @@ type DownloadResult struct {
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func preferredReleaseMetadata(
|
||||
req DownloadRequest,
|
||||
album string,
|
||||
releaseDate string,
|
||||
trackNumber int,
|
||||
discNumber int,
|
||||
) (string, string, int, int) {
|
||||
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
||||
if preferredAlbum == "" {
|
||||
preferredAlbum = album
|
||||
}
|
||||
|
||||
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
||||
if preferredReleaseDate == "" {
|
||||
preferredReleaseDate = releaseDate
|
||||
}
|
||||
|
||||
preferredTrackNumber := req.TrackNumber
|
||||
if preferredTrackNumber == 0 {
|
||||
preferredTrackNumber = trackNumber
|
||||
}
|
||||
|
||||
preferredDiscNumber := req.DiscNumber
|
||||
if preferredDiscNumber == 0 {
|
||||
preferredDiscNumber = discNumber
|
||||
}
|
||||
|
||||
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
||||
}
|
||||
|
||||
func buildDownloadSuccessResponse(
|
||||
req DownloadRequest,
|
||||
result DownloadResult,
|
||||
@@ -153,25 +184,16 @@ func buildDownloadSuccessResponse(
|
||||
artist = req.ArtistName
|
||||
}
|
||||
|
||||
album := result.Album
|
||||
if album == "" {
|
||||
album = req.AlbumName
|
||||
}
|
||||
|
||||
releaseDate := result.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
releaseDate = req.ReleaseDate
|
||||
}
|
||||
|
||||
trackNumber := result.TrackNumber
|
||||
if trackNumber == 0 {
|
||||
trackNumber = req.TrackNumber
|
||||
}
|
||||
|
||||
discNumber := result.DiscNumber
|
||||
if discNumber == 0 {
|
||||
discNumber = req.DiscNumber
|
||||
}
|
||||
// Preserve requested release metadata when available so mixed-provider
|
||||
// fallback downloads from the same source album do not get split into
|
||||
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
result.Album,
|
||||
result.ReleaseDate,
|
||||
result.TrackNumber,
|
||||
result.DiscNumber,
|
||||
)
|
||||
|
||||
isrc := result.ISRC
|
||||
if isrc == "" {
|
||||
@@ -193,6 +215,11 @@ func buildDownloadSuccessResponse(
|
||||
copyright = req.Copyright
|
||||
}
|
||||
|
||||
coverURL := strings.TrimSpace(result.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(req.CoverURL)
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
@@ -209,7 +236,7 @@ func buildDownloadSuccessResponse(
|
||||
TrackNumber: trackNumber,
|
||||
DiscNumber: discNumber,
|
||||
ISRC: isrc,
|
||||
CoverURL: req.CoverURL,
|
||||
CoverURL: coverURL,
|
||||
Genre: genre,
|
||||
Label: label,
|
||||
Copyright: copyright,
|
||||
@@ -262,7 +289,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
|
||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -284,8 +311,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Genre != "" || req.Label != "" {
|
||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
|
||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
@@ -562,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||
@@ -715,6 +747,26 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
}
|
||||
}
|
||||
} else if isM4A {
|
||||
meta, err := ReadM4ATags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
result["artist"] = meta.Artist
|
||||
result["album"] = meta.Album
|
||||
result["album_artist"] = meta.AlbumArtist
|
||||
result["date"] = meta.Date
|
||||
if meta.Date == "" {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
result["label"] = meta.Label
|
||||
result["copyright"] = meta.Copyright
|
||||
result["composer"] = meta.Composer
|
||||
result["comment"] = meta.Comment
|
||||
}
|
||||
quality, qualityErr := GetM4AQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
@@ -1103,6 +1155,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
@@ -1156,6 +1238,66 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetQobuzMetadata(resourceType, resourceID string) (string, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
var data interface{}
|
||||
var err error
|
||||
|
||||
switch resourceType {
|
||||
case "track":
|
||||
data, err = downloader.GetTrackMetadata(resourceID)
|
||||
case "album":
|
||||
data, err = downloader.GetAlbumMetadata(resourceID)
|
||||
case "artist":
|
||||
data, err = downloader.GetArtistMetadata(resourceID)
|
||||
case "playlist":
|
||||
data, err = downloader.GetPlaylistMetadata(resourceID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetTidalMetadata(resourceType, resourceID string) (string, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
var data interface{}
|
||||
var err error
|
||||
|
||||
switch resourceType {
|
||||
case "track":
|
||||
data, err = downloader.GetTrackMetadata(resourceID)
|
||||
case "album":
|
||||
data, err = downloader.GetAlbumMetadata(resourceID)
|
||||
case "artist":
|
||||
data, err = downloader.GetArtistMetadata(resourceID)
|
||||
case "playlist":
|
||||
data, err = downloader.GetPlaylistMetadata(resourceID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ParseDeezerURLExport(url string) (string, error) {
|
||||
resourceType, resourceID, err := parseDeezerURL(url)
|
||||
if err != nil {
|
||||
@@ -1175,6 +1317,25 @@ func ParseDeezerURLExport(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ParseQobuzURLExport(url string) (string, error) {
|
||||
resourceType, resourceID, err := parseQobuzURL(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"type": resourceType,
|
||||
"id": resourceID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ParseTidalURLExport(url string) (string, error) {
|
||||
resourceType, resourceID, err := parseTidalURL(url)
|
||||
if err != nil {
|
||||
@@ -1235,10 +1396,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
}
|
||||
result := buildDeezerExtendedMetadataResult(metadata)
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
@@ -1258,7 +1416,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(track)
|
||||
result := buildDeezerISRCSearchResult(track)
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1266,6 +1425,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
|
||||
if metadata == nil {
|
||||
return map[string]string{
|
||||
"genre": "",
|
||||
"label": "",
|
||||
"copyright": "",
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
"copyright": metadata.Copyright,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
|
||||
if track == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"spotify_id": track.SpotifyID,
|
||||
"artists": track.Artists,
|
||||
"name": track.Name,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.Images,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"external_urls": track.ExternalURL,
|
||||
"isrc": track.ISRC,
|
||||
"album_id": track.AlbumID,
|
||||
"artist_id": track.ArtistID,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
|
||||
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||
result["id"] = deezerID
|
||||
result["track_id"] = deezerID
|
||||
result["success"] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -1311,7 +1519,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// For artists/playlists, SongLink doesn't provide direct mapping
|
||||
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
|
||||
}
|
||||
|
||||
@@ -1545,6 +1752,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
coverData, err = ExtractCoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
coverData, err = extractCoverFromM4A(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".mp3") {
|
||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
@@ -1675,83 +1884,58 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
|
||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||
|
||||
// When search_online is true, search for metadata from internet.
|
||||
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc)
|
||||
// When search_online is true, search for metadata from internet using the
|
||||
// configured metadata-provider priority.
|
||||
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
|
||||
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
|
||||
searchQuery := req.TrackName + " " + req.ArtistName
|
||||
found := false
|
||||
|
||||
// 1) Try Deezer first (reliable, no credentials needed)
|
||||
GoLog("[ReEnrich] Trying Deezer search...\n")
|
||||
deezerClient := GetDeezerClient()
|
||||
{
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track")
|
||||
cancel()
|
||||
if err == nil && len(deezerResults.Tracks) > 0 {
|
||||
track := deezerResults.Tracks[0]
|
||||
GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
|
||||
req.SpotifyID = "deezer:" + track.SpotifyID
|
||||
req.AlbumName = track.AlbumName
|
||||
req.AlbumArtist = track.AlbumArtist
|
||||
req.TrackNumber = track.TrackNumber
|
||||
req.DiscNumber = track.DiscNumber
|
||||
req.ReleaseDate = track.ReleaseDate
|
||||
req.ISRC = track.ISRC
|
||||
if track.Images != "" {
|
||||
req.CoverURL = track.Images
|
||||
}
|
||||
req.DurationMs = int64(track.DurationMS)
|
||||
found = true
|
||||
} else if err != nil {
|
||||
GoLog("[ReEnrich] Deezer search failed: %v\n", err)
|
||||
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
|
||||
}
|
||||
req.AlbumName = track.AlbumName
|
||||
req.AlbumArtist = track.AlbumArtist
|
||||
req.TrackNumber = track.TrackNumber
|
||||
req.DiscNumber = track.DiscNumber
|
||||
req.ReleaseDate = track.ReleaseDate
|
||||
req.ISRC = track.ISRC
|
||||
coverURL := track.ResolvedCoverURL()
|
||||
if coverURL != "" {
|
||||
req.CoverURL = coverURL
|
||||
}
|
||||
req.DurationMs = int64(track.DurationMS)
|
||||
if track.Genre != "" {
|
||||
req.Genre = track.Genre
|
||||
}
|
||||
if track.Label != "" {
|
||||
req.Label = track.Label
|
||||
}
|
||||
if track.Copyright != "" {
|
||||
req.Copyright = track.Copyright
|
||||
}
|
||||
found = true
|
||||
} else if searchErr != nil {
|
||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get extended metadata (genre, label) from Deezer if not already set
|
||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
||||
// Try to get extended metadata from Deezer if not already set
|
||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
@@ -1762,7 +1946,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1831,8 +2018,15 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch lyrics
|
||||
// Preserve existing lyrics when online enrichment does not return a replacement.
|
||||
var lyricsLRC string
|
||||
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
|
||||
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
|
||||
lyricsLRC = existingLyrics
|
||||
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
|
||||
}
|
||||
|
||||
// Fetch lyrics
|
||||
if req.EmbedLyrics {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(req.DurationMs) / 1000.0
|
||||
@@ -1913,7 +2107,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// MP3/Opus: return metadata map for Dart to use FFmpeg
|
||||
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||
cleanupCover = false
|
||||
result := map[string]interface{}{
|
||||
@@ -2149,6 +2342,21 @@ func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(tracks)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
@@ -2534,6 +2742,28 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
artistResponse["albums"] = albums
|
||||
}
|
||||
|
||||
if len(result.Artist.Releases) > 0 {
|
||||
releases := make([]map[string]interface{}, len(result.Artist.Releases))
|
||||
for i, release := range result.Artist.Releases {
|
||||
releaseType := release.AlbumType
|
||||
if releaseType == "" {
|
||||
releaseType = "album"
|
||||
}
|
||||
releases[i] = map[string]interface{}{
|
||||
"id": release.ID,
|
||||
"name": release.Name,
|
||||
"artists": release.Artists,
|
||||
"images": release.CoverURL,
|
||||
"cover_url": release.CoverURL,
|
||||
"release_date": release.ReleaseDate,
|
||||
"total_tracks": release.TotalTracks,
|
||||
"album_type": releaseType,
|
||||
"provider_id": release.ProviderID,
|
||||
}
|
||||
}
|
||||
artistResponse["releases"] = releases
|
||||
}
|
||||
|
||||
if len(result.Artist.TopTracks) > 0 {
|
||||
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
||||
for i, track := range result.Artist.TopTracks {
|
||||
@@ -2783,6 +3013,27 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
"provider_id": artist.ProviderID,
|
||||
}
|
||||
|
||||
if len(artist.Releases) > 0 {
|
||||
releases := make([]map[string]interface{}, len(artist.Releases))
|
||||
for i, release := range artist.Releases {
|
||||
releaseType := release.AlbumType
|
||||
if releaseType == "" {
|
||||
releaseType = "album"
|
||||
}
|
||||
releases[i] = map[string]interface{}{
|
||||
"id": release.ID,
|
||||
"name": release.Name,
|
||||
"artists": release.Artists,
|
||||
"cover_url": release.CoverURL,
|
||||
"release_date": release.ReleaseDate,
|
||||
"total_tracks": release.TotalTracks,
|
||||
"album_type": releaseType,
|
||||
"provider_id": release.ProviderID,
|
||||
}
|
||||
}
|
||||
response["releases"] = releases
|
||||
}
|
||||
|
||||
if artist.HeaderImage != "" {
|
||||
response["header_image"] = artist.HeaderImage
|
||||
}
|
||||
@@ -2930,6 +3181,45 @@ func InitExtensionStoreJSON(cacheDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetStoreRegistryURLJSON(registryURL string) error {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
resolved, err := ResolveRegistryURL(registryURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(resolved, "registry"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store.SetRegistryURL(resolved)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearStoreRegistryURLJSON() error {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
store.SetRegistryURL("")
|
||||
store.ClearCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetStoreRegistryURLJSON() (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
return store.GetRegistryURL(), nil
|
||||
}
|
||||
|
||||
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
@@ -3087,6 +3377,10 @@ func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (str
|
||||
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
|
||||
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
|
||||
}
|
||||
|
||||
func GetLibraryScanProgressJSON() string {
|
||||
return GetLibraryScanProgress()
|
||||
}
|
||||
@@ -3098,3 +3392,7 @@ func CancelLibraryScanJSON() {
|
||||
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||
return ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayName(filePath, displayName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(nil)
|
||||
|
||||
if result["genre"] != "" {
|
||||
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "" {
|
||||
t.Fatalf("expected empty label, got %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "" {
|
||||
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||
Genre: "Rock",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Queen",
|
||||
})
|
||||
|
||||
if result["genre"] != "Rock" {
|
||||
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "EMI" {
|
||||
t.Fatalf("unexpected label: %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "(C) Queen" {
|
||||
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||
SpotifyID: "deezer:3135556",
|
||||
Name: "Love Of My Life",
|
||||
Artists: "Queen",
|
||||
AlbumName: "A Night at the Opera",
|
||||
ISRC: "GBUM71029604",
|
||||
ReleaseDate: "1975-11-21",
|
||||
})
|
||||
|
||||
if result["spotify_id"] != "deezer:3135556" {
|
||||
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||
}
|
||||
if result["id"] != "3135556" {
|
||||
t.Fatalf("unexpected id: %v", result["id"])
|
||||
}
|
||||
if result["track_id"] != "3135556" {
|
||||
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||
}
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected success=true, got %v", result["success"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Bonus Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album (Deluxe)",
|
||||
AlbumArtist: "Artist",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 14,
|
||||
DiscNumber: 1,
|
||||
ISRC: "REQ123",
|
||||
CoverURL: "https://example.com/cover.jpg",
|
||||
Genre: "Pop",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Bonus Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
ReleaseDate: "2023-12-01",
|
||||
TrackNumber: 2,
|
||||
DiscNumber: 9,
|
||||
ISRC: "RES456",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"tidal",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Album != req.AlbumName {
|
||||
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
||||
}
|
||||
if resp.ReleaseDate != req.ReleaseDate {
|
||||
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
||||
}
|
||||
if resp.TrackNumber != req.TrackNumber {
|
||||
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
||||
}
|
||||
if resp.DiscNumber != req.DiscNumber {
|
||||
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
||||
}
|
||||
if resp.Artist != result.Artist {
|
||||
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
||||
}
|
||||
if resp.ISRC != result.ISRC {
|
||||
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
DownloadRequest{
|
||||
AlbumName: "Album (Deluxe Edition)",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 13,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
"Album",
|
||||
"2023-01-01",
|
||||
3,
|
||||
1,
|
||||
)
|
||||
|
||||
if album != "Album (Deluxe Edition)" {
|
||||
t.Fatalf("album = %q", album)
|
||||
}
|
||||
if releaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q", releaseDate)
|
||||
}
|
||||
if trackNumber != 13 {
|
||||
t.Fatalf("track number = %d", trackNumber)
|
||||
}
|
||||
if discNumber != 2 {
|
||||
t.Fatalf("disc number = %d", discNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"qobuz",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.CoverURL != result.CoverURL {
|
||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
if exists {
|
||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||
if versionCompare > 0 {
|
||||
// This is an upgrade - call UpgradeExtension
|
||||
return m.UpgradeExtension(filePath)
|
||||
} else if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||
@@ -429,7 +428,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
SourceDir: dirPath,
|
||||
}
|
||||
|
||||
// Restore enabled state from settings store
|
||||
store := GetExtensionSettingsStore()
|
||||
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
||||
if enabled, ok := enabledVal.(bool); ok {
|
||||
|
||||
@@ -70,6 +70,7 @@ type ExtArtistMetadata struct {
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
Listeners int `json:"listeners,omitempty"`
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
@@ -327,6 +328,12 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
}
|
||||
|
||||
artist.ProviderID = p.extension.ID
|
||||
for i := range artist.Releases {
|
||||
artist.Releases[i].ProviderID = p.extension.ID
|
||||
for j := range artist.Releases[i].Tracks {
|
||||
artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
@@ -484,7 +491,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
return &urlResult, nil
|
||||
}
|
||||
|
||||
const ExtDownloadTimeout = 5 * time.Minute
|
||||
const ExtDownloadTimeout = DownloadTimeout
|
||||
|
||||
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
@@ -600,8 +607,30 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var allTracks []ExtTrackMetadata
|
||||
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
|
||||
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
providerByID[provider.extension.ID] = provider
|
||||
}
|
||||
for _, providerID := range GetMetadataProviderPriority() {
|
||||
if provider := providerByID[providerID]; provider != nil {
|
||||
orderedProviders = append(orderedProviders, provider)
|
||||
delete(providerByID, providerID)
|
||||
}
|
||||
}
|
||||
if len(providerByID) > 0 {
|
||||
remainingIDs := make([]string, 0, len(providerByID))
|
||||
for providerID := range providerByID {
|
||||
remainingIDs = append(remainingIDs, providerID)
|
||||
}
|
||||
sort.Strings(remainingIDs)
|
||||
for _, providerID := range remainingIDs {
|
||||
orderedProviders = append(orderedProviders, providerByID[providerID])
|
||||
}
|
||||
}
|
||||
|
||||
var allTracks []ExtTrackMetadata
|
||||
for _, provider := range orderedProviders {
|
||||
result, err := provider.SearchTracks(query, limit)
|
||||
if err != nil {
|
||||
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
||||
@@ -621,6 +650,8 @@ var providerPriorityMu sync.RWMutex
|
||||
var metadataProviderPriority []string
|
||||
var metadataProviderPriorityMu sync.RWMutex
|
||||
|
||||
var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
|
||||
|
||||
func SetProviderPriority(providerIDs []string) {
|
||||
providerPriorityMu.Lock()
|
||||
defer providerPriorityMu.Unlock()
|
||||
@@ -645,7 +676,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
||||
metadataProviderPriorityMu.Lock()
|
||||
defer metadataProviderPriorityMu.Unlock()
|
||||
|
||||
sanitized := make([]string, 0, len(providerIDs)+1)
|
||||
sanitized := make([]string, 0, len(providerIDs)+3)
|
||||
seen := map[string]struct{}{}
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
@@ -658,8 +689,12 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
if _, exists := seen["deezer"]; !exists {
|
||||
sanitized = append([]string{"deezer"}, sanitized...)
|
||||
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
metadataProviderPriority = sanitized
|
||||
@@ -671,7 +706,7 @@ func GetMetadataProviderPriority() []string {
|
||||
defer metadataProviderPriorityMu.RUnlock()
|
||||
|
||||
if len(metadataProviderPriority) == 0 {
|
||||
return []string{"deezer"}
|
||||
return []string{"deezer", "qobuz", "tidal"}
|
||||
}
|
||||
|
||||
result := make([]string, len(metadataProviderPriority))
|
||||
@@ -688,6 +723,165 @@ func isBuiltInProvider(providerID string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
|
||||
deezerID := ""
|
||||
tidalID := ""
|
||||
qobuzID := ""
|
||||
prefixedID := strings.TrimSpace(track.SpotifyID)
|
||||
|
||||
switch providerID {
|
||||
case "deezer":
|
||||
deezerID = strings.TrimPrefix(prefixedID, "deezer:")
|
||||
case "tidal":
|
||||
tidalID = strings.TrimPrefix(prefixedID, "tidal:")
|
||||
case "qobuz":
|
||||
qobuzID = strings.TrimPrefix(prefixedID, "qobuz:")
|
||||
}
|
||||
|
||||
return ExtTrackMetadata{
|
||||
ID: prefixedID,
|
||||
Name: track.Name,
|
||||
Artists: track.Artists,
|
||||
AlbumName: track.AlbumName,
|
||||
AlbumArtist: track.AlbumArtist,
|
||||
DurationMS: track.DurationMS,
|
||||
CoverURL: track.Images,
|
||||
Images: track.Images,
|
||||
ReleaseDate: track.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
DiscNumber: track.DiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
ProviderID: providerID,
|
||||
SpotifyID: prefixedID,
|
||||
DeezerID: deezerID,
|
||||
TidalID: tidalID,
|
||||
QobuzID: qobuzID,
|
||||
AlbumType: track.AlbumType,
|
||||
}
|
||||
}
|
||||
|
||||
func metadataTrackDedupKey(track ExtTrackMetadata) string {
|
||||
if isrc := strings.TrimSpace(track.ISRC); isrc != "" {
|
||||
return "isrc:" + strings.ToUpper(isrc)
|
||||
}
|
||||
if spotifyID := strings.TrimSpace(track.SpotifyID); spotifyID != "" {
|
||||
return "spotify:" + spotifyID
|
||||
}
|
||||
if providerID := strings.TrimSpace(track.ProviderID); providerID != "" && strings.TrimSpace(track.ID) != "" {
|
||||
return providerID + ":" + strings.TrimSpace(track.ID)
|
||||
}
|
||||
return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists)
|
||||
}
|
||||
|
||||
func searchBuiltInMetadataTracks(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
switch providerID {
|
||||
case "deezer":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results, err := GetDeezerClient().SearchAll(ctx, query, limit, 0, "track")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracks := make([]ExtTrackMetadata, 0, len(results.Tracks))
|
||||
for _, track := range results.Tracks {
|
||||
tracks = append(tracks, normalizeBuiltInMetadataTrack(track, "deezer"))
|
||||
}
|
||||
return tracks, nil
|
||||
case "qobuz":
|
||||
return NewQobuzDownloader().SearchTracks(query, limit)
|
||||
case "tidal":
|
||||
return NewTidalDownloader().SearchTracks(query, limit)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported built-in metadata provider: %s", providerID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) {
|
||||
priority := GetMetadataProviderPriority()
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
extensionProviders := make(map[string]*ExtensionProviderWrapper)
|
||||
if includeExtensions {
|
||||
for _, provider := range m.GetMetadataProviders() {
|
||||
extensionProviders[provider.extension.ID] = provider
|
||||
}
|
||||
}
|
||||
|
||||
orderedProviderIDs := make([]string, 0, len(priority)+len(extensionProviders))
|
||||
seenProviderIDs := make(map[string]struct{}, len(priority)+len(extensionProviders))
|
||||
for _, providerID := range priority {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
orderedProviderIDs = append(orderedProviderIDs, providerID)
|
||||
seenProviderIDs[providerID] = struct{}{}
|
||||
}
|
||||
if includeExtensions {
|
||||
remainingIDs := make([]string, 0, len(extensionProviders))
|
||||
for providerID := range extensionProviders {
|
||||
if _, exists := seenProviderIDs[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
remainingIDs = append(remainingIDs, providerID)
|
||||
}
|
||||
sort.Strings(remainingIDs)
|
||||
orderedProviderIDs = append(orderedProviderIDs, remainingIDs...)
|
||||
}
|
||||
|
||||
tracks := make([]ExtTrackMetadata, 0, limit)
|
||||
seenTracks := make(map[string]struct{})
|
||||
for _, providerID := range orderedProviderIDs {
|
||||
var (
|
||||
providerTracks []ExtTrackMetadata
|
||||
err error
|
||||
)
|
||||
|
||||
if isBuiltInProvider(providerID) {
|
||||
providerTracks, err = searchBuiltInMetadataTracksFunc(providerID, query, limit)
|
||||
} else {
|
||||
if !includeExtensions {
|
||||
continue
|
||||
}
|
||||
provider := extensionProviders[providerID]
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
var result *ExtSearchResult
|
||||
result, err = provider.SearchTracks(query, limit)
|
||||
if result != nil {
|
||||
providerTracks = result.Tracks
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, track := range providerTracks {
|
||||
key := metadataTrackDedupKey(track)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenTracks[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenTracks[key] = struct{}{}
|
||||
tracks = append(tracks, track)
|
||||
if len(tracks) >= limit {
|
||||
return tracks, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||
priority := GetProviderPriority()
|
||||
extManager := GetExtensionManager()
|
||||
@@ -783,6 +977,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if enrichedTrack.Artists != "" {
|
||||
req.ArtistName = enrichedTrack.Artists
|
||||
}
|
||||
if enrichedTrack.AlbumName != "" && req.AlbumName == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName)
|
||||
req.AlbumName = enrichedTrack.AlbumName
|
||||
}
|
||||
if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" {
|
||||
req.AlbumArtist = enrichedTrack.AlbumArtist
|
||||
}
|
||||
if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 {
|
||||
GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS)
|
||||
req.DurationMS = enrichedTrack.DurationMS
|
||||
}
|
||||
if enrichedTrack.CoverURL != "" && req.CoverURL == "" {
|
||||
req.CoverURL = enrichedTrack.CoverURL
|
||||
}
|
||||
if enrichedTrack.ID != "" && req.SpotifyID == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID)
|
||||
req.SpotifyID = enrichedTrack.ID
|
||||
}
|
||||
if enrichedTrack.Label != "" && req.Label == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||
req.Label = enrichedTrack.Label
|
||||
@@ -803,6 +1015,77 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// If key metadata is still missing after extension enrichment, search
|
||||
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
|
||||
// logic that ReEnrichFile uses.
|
||||
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
req.TrackName != "" && req.ArtistName != "" &&
|
||||
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
||||
|
||||
searchQuery := req.TrackName + " " + req.ArtistName
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
||||
|
||||
tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := tracks[0]
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
|
||||
|
||||
if track.AlbumName != "" && req.AlbumName == "" {
|
||||
req.AlbumName = track.AlbumName
|
||||
}
|
||||
if track.AlbumArtist != "" && req.AlbumArtist == "" {
|
||||
req.AlbumArtist = track.AlbumArtist
|
||||
}
|
||||
if track.ReleaseDate != "" && req.ReleaseDate == "" {
|
||||
req.ReleaseDate = track.ReleaseDate
|
||||
}
|
||||
if track.ISRC != "" && req.ISRC == "" {
|
||||
req.ISRC = track.ISRC
|
||||
}
|
||||
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||
req.DiscNumber = track.DiscNumber
|
||||
}
|
||||
if track.CoverURL != "" && req.CoverURL == "" {
|
||||
req.CoverURL = track.CoverURL
|
||||
}
|
||||
if track.Genre != "" && req.Genre == "" {
|
||||
req.Genre = track.Genre
|
||||
}
|
||||
if track.Label != "" && req.Label == "" {
|
||||
req.Label = track.Label
|
||||
}
|
||||
if track.Copyright != "" && req.Copyright == "" {
|
||||
req.Copyright = track.Copyright
|
||||
}
|
||||
} else if searchErr != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||
}
|
||||
|
||||
// Try Deezer extended metadata if we have ISRC
|
||||
if req.ISRC != "" &&
|
||||
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
if err == nil && extMeta != nil {
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" &&
|
||||
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
|
||||
@@ -896,6 +1179,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
// Always pass enriched metadata from req so Flutter can
|
||||
// embed it — fills gaps from metadata provider search.
|
||||
if req.AlbumName != "" && resp.Album == "" {
|
||||
resp.Album = req.AlbumName
|
||||
}
|
||||
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
|
||||
resp.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
|
||||
resp.ReleaseDate = req.ReleaseDate
|
||||
}
|
||||
if req.ISRC != "" && resp.ISRC == "" {
|
||||
resp.ISRC = req.ISRC
|
||||
}
|
||||
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
|
||||
resp.TrackNumber = req.TrackNumber
|
||||
}
|
||||
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
|
||||
resp.DiscNumber = req.DiscNumber
|
||||
}
|
||||
if req.CoverURL != "" && resp.CoverURL == "" {
|
||||
resp.CoverURL = req.CoverURL
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -946,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInProvider(providerIDNormalized) {
|
||||
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
|
||||
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
||||
req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
deezerClient := GetDeezerClient()
|
||||
@@ -961,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
req.Label = extMeta.Label
|
||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
|
||||
}
|
||||
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||
req.Copyright = extMeta.Copyright
|
||||
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
|
||||
}
|
||||
} else if err != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||
}
|
||||
@@ -1168,6 +1480,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
@@ -1210,6 +1523,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
CoverURL: result.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
@@ -1449,6 +1763,12 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
for i := range handleResult.Artist.Releases {
|
||||
handleResult.Artist.Releases[i].ProviderID = p.extension.ID
|
||||
for j := range handleResult.Artist.Releases[i].Tracks {
|
||||
handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
for i := range handleResult.Artist.TopTracks {
|
||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
defer SetMetadataProviderPriority(original)
|
||||
|
||||
SetMetadataProviderPriority([]string{"tidal"})
|
||||
got := GetMetadataProviderPriority()
|
||||
want := []string{"tidal", "deezer", "qobuz"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
originalSearch := searchBuiltInMetadataTracksFunc
|
||||
defer func() {
|
||||
SetMetadataProviderPriority(originalPriority)
|
||||
searchBuiltInMetadataTracksFunc = originalSearch
|
||||
}()
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
||||
|
||||
var calls []string
|
||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
calls = append(calls, providerID)
|
||||
switch providerID {
|
||||
case "qobuz":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
|
||||
}, nil
|
||||
case "tidal":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
||||
}, nil
|
||||
case "deezer":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
}
|
||||
if len(tracks) != 3 {
|
||||
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
||||
}
|
||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
||||
t.Fatalf("unexpected track provider order: %+v", tracks)
|
||||
}
|
||||
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
||||
t.Fatalf("unexpected provider call order: %v", calls)
|
||||
}
|
||||
}
|
||||
@@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
downloadClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
@@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
@@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
return runtime
|
||||
return client
|
||||
}
|
||||
|
||||
type RedirectBlockedError struct {
|
||||
|
||||
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -129,9 +130,8 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
||||
cacheTTL = 30 * time.Minute
|
||||
cacheFileName = "store_cache.json"
|
||||
cacheTTL = 30 * time.Minute
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
@@ -140,7 +140,7 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
|
||||
if extensionStore == nil {
|
||||
extensionStore = &ExtensionStore{
|
||||
registryURL: defaultRegistryURL,
|
||||
registryURL: "", // No default - user must provide a registry URL
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
@@ -149,6 +149,36 @@ func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
||||
// so the next fetch will use the new URL. Disk cache is also cleared.
|
||||
func (s *ExtensionStore) SetRegistryURL(registryURL string) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
if s.registryURL == registryURL {
|
||||
return
|
||||
}
|
||||
|
||||
s.registryURL = registryURL
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
// Clear disk cache since it's from a different registry
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||
}
|
||||
|
||||
// GetRegistryURL returns the currently configured registry URL.
|
||||
func (s *ExtensionStore) GetRegistryURL() string {
|
||||
s.cacheMu.RLock()
|
||||
defer s.cacheMu.RUnlock()
|
||||
return s.registryURL
|
||||
}
|
||||
|
||||
func GetExtensionStore() *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
@@ -206,6 +236,11 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Check if a registry URL has been configured
|
||||
if s.registryURL == "" {
|
||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||
}
|
||||
|
||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
return s.cache, nil
|
||||
@@ -336,6 +371,81 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
|
||||
//
|
||||
// Accepted formats:
|
||||
// - https://raw.githubusercontent.com/owner/repo/<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)
|
||||
@@ -374,12 +484,10 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
// Filter by category
|
||||
if category != "" && ext.Category != category {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by query
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
|
||||
@@ -31,7 +31,7 @@ func getRandomUserAgent() string {
|
||||
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second
|
||||
DownloadTimeout = 120 * time.Second
|
||||
DownloadTimeout = 24 * time.Hour
|
||||
SongLinkTimeout = 30 * time.Second
|
||||
DefaultMaxRetries = 3
|
||||
DefaultRetryDelay = 1 * time.Second
|
||||
@@ -346,11 +346,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// Returns 60 seconds as default if header is missing or invalid
|
||||
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if retryAfter == "" {
|
||||
return 60 * time.Second // Default wait time
|
||||
return 0
|
||||
}
|
||||
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
@@ -364,7 +365,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
}
|
||||
}
|
||||
|
||||
return 60 * time.Second // Default
|
||||
return 0
|
||||
}
|
||||
|
||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
|
||||
+190
-66
@@ -1,10 +1,12 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -71,6 +73,11 @@ type libraryAudioFileInfo struct {
|
||||
modTime int64
|
||||
}
|
||||
|
||||
type scannedCueFileInfo struct {
|
||||
sheet *CueSheet
|
||||
audioPath string
|
||||
}
|
||||
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
@@ -144,12 +151,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
audioFiles := make([]string, 0, len(audioFileInfos))
|
||||
for _, fileInfo := range audioFileInfos {
|
||||
audioFiles = append(audioFiles, fileInfo.path)
|
||||
}
|
||||
|
||||
totalFiles := len(audioFiles)
|
||||
totalFiles := len(audioFileInfos)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
@@ -169,22 +171,29 @@ 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 _, filePath := range audioFiles {
|
||||
for _, fileInfo := range audioFileInfos {
|
||||
filePath := fileInfo.path
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext == ".cue" {
|
||||
sheet, err := ParseCueFile(filePath)
|
||||
if err == nil && sheet.FileName != "" {
|
||||
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
||||
if audioPath != "" {
|
||||
parsedCueFiles[filePath] = scannedCueFileInfo{
|
||||
sheet: sheet,
|
||||
audioPath: audioPath,
|
||||
}
|
||||
cueReferencedAudioFiles[audioPath] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, filePath := range audioFiles {
|
||||
for i, fileInfo := range audioFileInfos {
|
||||
filePath := fileInfo.path
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return "[]", fmt.Errorf("scan cancelled")
|
||||
@@ -201,7 +210,20 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
|
||||
// Handle .cue files: produce multiple track results
|
||||
if ext == ".cue" {
|
||||
cueResults, err := ScanCueFileForLibrary(filePath, scanTime)
|
||||
var cueResults []LibraryScanResult
|
||||
cueInfo, ok := parsedCueFiles[filePath]
|
||||
if ok {
|
||||
cueResults, err = scanCueSheetForLibrary(
|
||||
filePath,
|
||||
cueInfo.sheet,
|
||||
cueInfo.audioPath,
|
||||
"",
|
||||
fileInfo.modTime,
|
||||
scanTime,
|
||||
)
|
||||
} else {
|
||||
cueResults, err = ScanCueFileForLibrary(filePath, scanTime)
|
||||
}
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||
@@ -219,7 +241,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||
@@ -245,7 +267,15 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
}
|
||||
|
||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||
|
||||
result := &LibraryScanResult{
|
||||
ID: generateLibraryID(filePath),
|
||||
@@ -254,15 +284,17 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
if knownModTime > 0 {
|
||||
result.FileModTime = knownModTime
|
||||
} else if info, err := os.Stat(filePath); err == nil {
|
||||
result.FileModTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" && ext != ".m4a" {
|
||||
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
||||
if coverCacheDir != "" {
|
||||
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
}
|
||||
@@ -276,15 +308,31 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result)
|
||||
return scanOggFile(filePath, result, displayNameHint)
|
||||
default:
|
||||
return scanFromFilename(filePath, result)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
}
|
||||
|
||||
func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
||||
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext != "" {
|
||||
return ext
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(displayNameHint))
|
||||
}
|
||||
|
||||
func libraryDisplayNameOrPath(filePath, displayNameHint string) string {
|
||||
if displayNameHint != "" {
|
||||
return displayNameHint
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *LibraryScanResult) {
|
||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
@@ -297,7 +345,7 @@ func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, result)
|
||||
return scanFromFilename(filePath, "", result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -319,26 +367,43 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err == nil && metadata != nil {
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.ReleaseDate = metadata.Date
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
}
|
||||
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return scanFromFilename(filePath, result)
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
return scanFromFilename(filePath, "", result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -365,16 +430,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadOggVorbisComments(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
@@ -397,13 +462,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, result)
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||
|
||||
parts := strings.SplitN(filename, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
@@ -426,7 +492,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
result.AlbumName = filepath.Base(dir)
|
||||
if result.AlbumName == "." || result.AlbumName == "" {
|
||||
if result.AlbumName == "." || result.AlbumName == "" || result.AlbumName == "fd" || result.AlbumName == "self" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
@@ -473,8 +539,12 @@ func CancelLibraryScan() {
|
||||
}
|
||||
|
||||
func ReadAudioMetadata(filePath string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayName(filePath, "")
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -490,7 +560,43 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||
existingFiles := make(map[string]int64)
|
||||
if snapshotPath == "" {
|
||||
return existingFiles, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(snapshotPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
modTime, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
existingFiles[parts[1]] = modTime
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existingFiles, nil
|
||||
}
|
||||
|
||||
func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFiles map[string]int64) (string, error) {
|
||||
if folderPath == "" {
|
||||
return "{}", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
@@ -503,13 +609,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||
}
|
||||
|
||||
existingFiles := make(map[string]int64)
|
||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
@@ -541,14 +640,13 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
// Find files to scan (new or modified)
|
||||
var filesToScan []libraryAudioFileInfo
|
||||
skippedCount := 0
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,25 +655,12 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
if !exists {
|
||||
// For .cue files, also check if any virtual path entries exist
|
||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||
hasCueTracks := false
|
||||
for existingPath := range existingFiles {
|
||||
if strings.HasPrefix(existingPath, f.path+"#track") {
|
||||
hasCueTracks = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasCueTracks {
|
||||
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||
// CUE file exists in DB via virtual paths; check if modTime changed
|
||||
// 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
|
||||
}
|
||||
if f.modTime == cueTrackModTime {
|
||||
skippedCount++
|
||||
} else {
|
||||
filesToScan = append(filesToScan, f)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -630,6 +715,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
|
||||
// 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" {
|
||||
@@ -637,6 +723,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
if err == nil && sheet.FileName != "" {
|
||||
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
||||
if audioPath != "" {
|
||||
parsedCueFiles[f.path] = scannedCueFileInfo{
|
||||
sheet: sheet,
|
||||
audioPath: audioPath,
|
||||
}
|
||||
cueReferencedAudioFilesInc[audioPath] = true
|
||||
}
|
||||
}
|
||||
@@ -660,7 +750,20 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
|
||||
// Handle .cue files: produce multiple track results
|
||||
if ext == ".cue" {
|
||||
cueResults, err := ScanCueFileForLibrary(f.path, scanTime)
|
||||
var cueResults []LibraryScanResult
|
||||
cueInfo, ok := parsedCueFiles[f.path]
|
||||
if ok {
|
||||
cueResults, err = scanCueSheetForLibrary(
|
||||
f.path,
|
||||
cueInfo.sheet,
|
||||
cueInfo.audioPath,
|
||||
"",
|
||||
f.modTime,
|
||||
scanTime,
|
||||
)
|
||||
} else {
|
||||
cueResults, err = ScanCueFileForLibrary(f.path, scanTime)
|
||||
}
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||
@@ -675,7 +778,7 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := scanAudioFile(f.path, scanTime)
|
||||
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||
@@ -709,3 +812,24 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||
existingFiles := make(map[string]int64)
|
||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||
}
|
||||
}
|
||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath string) (string, error) {
|
||||
existingFiles, err := loadExistingFilesSnapshot(snapshotPath)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("failed to load incremental snapshot: %w", err)
|
||||
}
|
||||
return scanLibraryFolderIncrementalWithExistingFiles(folderPath, existingFiles)
|
||||
}
|
||||
|
||||
+107
-64
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string {
|
||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +432,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
if syncType == "" {
|
||||
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
|
||||
syncType = "LINE_SYNCED"
|
||||
} else {
|
||||
syncType = "UNSYNCED"
|
||||
}
|
||||
}
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: syncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: plainLyrics,
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||
parts := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, words)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
trimmed := strings.TrimSpace(lrcPayload)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(trimmed)
|
||||
if len(lines) > 0 {
|
||||
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(trimmed)
|
||||
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
lines := make([]LyricsLine, 0, len(apiResp.Lines))
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
nextStart := lines[i+1].StartTimeMs
|
||||
if nextStart > lines[i].StartTimeMs {
|
||||
lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
last := len(lines) - 1
|
||||
if lines[last].EndTimeMs == 0 {
|
||||
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
now := time.Now()
|
||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||
@@ -449,7 +543,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
spotifyID = parsed.ID
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
|
||||
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -462,13 +556,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
@@ -479,63 +578,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
result := &LyricsResponse{
|
||||
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
|
||||
SyncType: apiResp.SyncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: "",
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}
|
||||
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
result.Lines = append(result.Lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Lines) > 1 {
|
||||
for i := 0; i < len(result.Lines)-1; i++ {
|
||||
nextStart := result.Lines[i+1].StartTimeMs
|
||||
if nextStart > result.Lines[i].StartTimeMs {
|
||||
result.Lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
last := len(result.Lines) - 1
|
||||
if result.Lines[last].EndTimeMs == 0 {
|
||||
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
|
||||
if result.SyncType == "" {
|
||||
result.SyncType = "LINE_SYNCED"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return parseSpotifyLyricsResponseBody(bodyBytes)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
|
||||
+65
-126
@@ -4,121 +4,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||
// Uses Paxsenix endpoints for search and lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Apple Music token manager — singleton with mutex for thread safety
|
||||
type appleTokenManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
var globalAppleTokenManager = &appleTokenManager{}
|
||||
|
||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.token != "" {
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
// Step 1: Fetch the Apple Music beta page
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the index JS file URL
|
||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||
match := indexJsRegex.Find(body)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||
}
|
||||
|
||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||
|
||||
// Step 3: Fetch the JS file
|
||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
jsResp, err := client.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Extract JWT token (starts with eyJh)
|
||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||
tokenMatch := tokenRegex.Find(jsBody)
|
||||
if tokenMatch == nil {
|
||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||
}
|
||||
|
||||
m.token = string(tokenMatch)
|
||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *appleTokenManager) clearToken() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.token = ""
|
||||
}
|
||||
|
||||
type appleMusicSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
URL string `json:"url"`
|
||||
Artwork struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artwork"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
@@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
if normalizedArtist == "" {
|
||||
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := 0
|
||||
|
||||
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
|
||||
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
|
||||
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && result.Duration > 0 {
|
||||
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music token error: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||
encodedQuery,
|
||||
)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
globalAppleTokenManager.clearToken()
|
||||
return "", fmt.Errorf("apple music token expired")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicSearchResponse
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.ID) == "" {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return searchResp.Results.Songs.Data[0].ID, nil
|
||||
return strings.TrimSpace(best.ID), nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
baseURL: "http://158.180.60.95",
|
||||
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
||||
}
|
||||
}
|
||||
|
||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("empty track or artist name")
|
||||
return "", fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
encodedArtist := url.QueryEscape(artistName)
|
||||
encodedTrack := url.QueryEscape(trackName)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||
params := url.Values{}
|
||||
params.Set("t", trackName)
|
||||
params.Set("a", artistName)
|
||||
params.Set("type", lyricsType)
|
||||
params.Set("format", "lrc")
|
||||
if durationSec > 0 {
|
||||
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
|
||||
}
|
||||
if strings.TrimSpace(language) != "" {
|
||||
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
|
||||
}
|
||||
fullURL := c.baseURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||
return "", fmt.Errorf("musixmatch request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return "", fmt.Errorf("empty musixmatch lyrics payload")
|
||||
}
|
||||
return lrcPayload, nil
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode musixmatch response")
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if songID <= 0 || lang == "" {
|
||||
return nil, fmt.Errorf("invalid song id or language")
|
||||
if lang == "" {
|
||||
return nil, fmt.Errorf("invalid language")
|
||||
}
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
@@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||
// This is a direct public API — no proxy dependency.
|
||||
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
return 0, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "http://music.163.com/api/search/pc"
|
||||
searchURL := "https://lyrics.paxsenix.org/netease/search"
|
||||
params := url.Values{}
|
||||
params.Set("s", query)
|
||||
params.Set("type", "1")
|
||||
params.Set("limit", "1")
|
||||
params.Set("offset", "0")
|
||||
params.Set("q", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
@@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
params.Set("lv", "1")
|
||||
params.Set("tv", "1")
|
||||
params.Set("rv", "1")
|
||||
|
||||
fullURL := lyricsURL + "?" + params.Encode()
|
||||
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||
// Uses Paxsenix metadata lookup for lyrics.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type qqMusicSearchResponse struct {
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
type qqLyricsMetadataRequest struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album,omitempty"`
|
||||
SongID int64 `json:"songid,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// QQ Music lyrics request payload for paxsenix proxy
|
||||
type qqLyricsPayload struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
type qqLyricsMetadataResponse struct {
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty search query")
|
||||
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
|
||||
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||
payload := qqLyricsMetadataRequest{
|
||||
Artist: []string{artistName},
|
||||
Title: trackName,
|
||||
}
|
||||
if durationSec > 0 {
|
||||
payload.Duration = int64(math.Round(durationSec))
|
||||
}
|
||||
|
||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||
params := url.Values{}
|
||||
params.Set("format", "json")
|
||||
params.Set("inCharset", "utf8")
|
||||
params.Set("outCharset", "utf8")
|
||||
params.Set("platform", "yqq.json")
|
||||
params.Set("new_json", "1")
|
||||
params.Set("w", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp qqMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Data.Song.List) == 0 {
|
||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||
}
|
||||
|
||||
song := searchResp.Data.Song.List[0]
|
||||
|
||||
var artists []string
|
||||
for _, singer := range song.Singer {
|
||||
artists = append(artists, singer.Name)
|
||||
}
|
||||
|
||||
return &qqLyricsPayload{
|
||||
Artist: artists,
|
||||
Album: song.Album.Name,
|
||||
ID: song.ID,
|
||||
Title: song.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
var response qqLyricsMetadataResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
|
||||
}
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
payload, err := c.searchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to use as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
|
||||
+320
-6
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
lyrics, err := extractLyricsFromM4A(filePath)
|
||||
if err == nil && strings.TrimSpace(lyrics) != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".mp3") {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
@@ -581,6 +589,299 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ilst, err := findM4AIlstAtom(f, fi.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
start := ilst.offset + ilst.headerSize
|
||||
end := ilst.offset + ilst.size
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fi.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = end - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return nil, fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
switch header.typ {
|
||||
case "\xa9nam":
|
||||
metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9ART":
|
||||
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9alb":
|
||||
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "aART":
|
||||
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9day":
|
||||
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
|
||||
metadata.Year = metadata.Date
|
||||
case "\xa9gen":
|
||||
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9wrt":
|
||||
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9cmt":
|
||||
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "cprt":
|
||||
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9lyr":
|
||||
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "trkn":
|
||||
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
case "disk":
|
||||
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
case "----":
|
||||
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
||||
if freeformErr == nil {
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LABEL", "ORGANIZATION":
|
||||
metadata.Label = value
|
||||
case "COMMENT":
|
||||
if metadata.Comment == "" {
|
||||
metadata.Comment = value
|
||||
}
|
||||
case "COMPOSER":
|
||||
if metadata.Composer == "" {
|
||||
metadata.Composer = value
|
||||
}
|
||||
case "COPYRIGHT":
|
||||
if metadata.Copyright == "" {
|
||||
metadata.Copyright = value
|
||||
}
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
if metadata.Title == "" &&
|
||||
metadata.Artist == "" &&
|
||||
metadata.Album == "" &&
|
||||
metadata.AlbumArtist == "" &&
|
||||
metadata.Lyrics == "" &&
|
||||
metadata.TrackNumber == 0 &&
|
||||
metadata.DiscNumber == 0 {
|
||||
return nil, fmt.Errorf("no M4A tags found")
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromM4A(filePath string) (string, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
return metadata.Lyrics, nil
|
||||
}
|
||||
|
||||
func extractCoverFromM4A(filePath string) ([]byte, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
ilst, err := findM4AIlstAtom(f, fileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bodyStart := ilst.offset + ilst.headerSize
|
||||
bodySize := ilst.size - ilst.headerSize
|
||||
|
||||
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("cover atom not found")
|
||||
}
|
||||
|
||||
dataStart := covr.offset + covr.headerSize
|
||||
dataSize := covr.size - covr.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("data atom not found in cover")
|
||||
}
|
||||
|
||||
// data atom: header + 4 bytes type indicator + 4 bytes locale
|
||||
imgStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
imgLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if imgLen <= 0 {
|
||||
return nil, fmt.Errorf("empty cover data")
|
||||
}
|
||||
|
||||
buf := make([]byte, imgLen)
|
||||
if _, err := f.ReadAt(buf, imgStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
|
||||
// It tries two common layouts:
|
||||
// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
|
||||
// 2. moov > meta > ilst (some encoders omit the udta wrapper)
|
||||
func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return atomHeader{}, fmt.Errorf("moov not found")
|
||||
}
|
||||
|
||||
moovBodyStart := moov.offset + moov.headerSize
|
||||
moovBodySize := moov.size - moov.headerSize
|
||||
|
||||
// Path 1: moov > udta > meta > ilst
|
||||
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
|
||||
udtaBodyStart := udta.offset + udta.headerSize
|
||||
udtaBodySize := udta.size - udta.headerSize
|
||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path 2: moov > meta > ilst (no udta wrapper)
|
||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
|
||||
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
||||
}
|
||||
|
||||
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
|
||||
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
payloadLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if payloadLen <= 0 {
|
||||
return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
|
||||
}
|
||||
|
||||
buf := make([]byte, payloadLen)
|
||||
if _, err := f.ReadAt(buf, payloadStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
|
||||
dataStart := parent.offset + parent.headerSize
|
||||
dataSize := parent.size - parent.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("data atom not found in %s", parent.typ)
|
||||
}
|
||||
return readM4ADataAtomPayload(f, dataAtom)
|
||||
}
|
||||
|
||||
func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
|
||||
}
|
||||
|
||||
func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(payload) < 4 {
|
||||
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
||||
}
|
||||
|
||||
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
|
||||
start := parent.offset + parent.headerSize
|
||||
end := parent.offset + parent.size
|
||||
|
||||
var nameValue string
|
||||
var dataValue string
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = end - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
switch header.typ {
|
||||
case "mean":
|
||||
// Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
|
||||
case "name":
|
||||
// The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
|
||||
// It does NOT contain a nested "data" atom, so read the payload directly.
|
||||
payloadStart := header.offset + header.headerSize + 4
|
||||
payloadLen := header.size - header.headerSize - 4
|
||||
if payloadLen > 0 {
|
||||
buf := make([]byte, payloadLen)
|
||||
if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
|
||||
nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
|
||||
}
|
||||
}
|
||||
case "data":
|
||||
payload, payloadErr := readM4ADataAtomPayload(f, header)
|
||||
if payloadErr == nil {
|
||||
dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
|
||||
}
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
if nameValue == "" || dataValue == "" {
|
||||
return "", "", fmt.Errorf("freeform M4A tag incomplete")
|
||||
}
|
||||
|
||||
return nameValue, dataValue, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
@@ -743,15 +1044,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 24)
|
||||
buf := make([]byte, 32)
|
||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||
}
|
||||
|
||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
||||
bitDepth := 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
// AudioSampleEntry layout from the box type field:
|
||||
// [0:4] type ("mp4a"/"alac")
|
||||
// [4:10] SampleEntry.reserved
|
||||
// [10:12] data_reference_index
|
||||
// [12:20] reserved[8]
|
||||
// [20:22] channelcount
|
||||
// [22:24] samplesize (bit depth)
|
||||
// [24:26] pre_defined
|
||||
// [26:28] reserved
|
||||
// [28:32] samplerate (16.16 fixed-point)
|
||||
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
}
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
@@ -874,7 +1188,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
|
||||
|
||||
if bestIdx >= 0 {
|
||||
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
||||
if absolute+24 > fileSize {
|
||||
if absolute+32 > fileSize {
|
||||
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||
}
|
||||
return absolute, bestType, nil
|
||||
|
||||
+31
-4
@@ -34,10 +34,16 @@ var (
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
multiProgressDirty = true
|
||||
cachedMultiProgress = "{\"items\":{}}"
|
||||
)
|
||||
|
||||
func markMultiProgressDirtyLocked() {
|
||||
multiProgressDirty = true
|
||||
}
|
||||
|
||||
func getProgress() DownloadProgress {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -58,13 +64,25 @@ func getProgress() DownloadProgress {
|
||||
|
||||
func GetMultiProgress() string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
if !multiProgressDirty {
|
||||
cached := cachedMultiProgress
|
||||
multiMu.RUnlock()
|
||||
return cached
|
||||
}
|
||||
multiMu.RUnlock()
|
||||
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
if !multiProgressDirty {
|
||||
return cachedMultiProgress
|
||||
}
|
||||
jsonBytes, err := json.Marshal(multiProgress)
|
||||
if err != nil {
|
||||
return "{\"items\":{}}"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
cachedMultiProgress = string(jsonBytes)
|
||||
multiProgressDirty = false
|
||||
return cachedMultiProgress
|
||||
}
|
||||
|
||||
func GetItemProgress(itemID string) string {
|
||||
@@ -90,6 +108,7 @@ func StartItemProgress(itemID string) {
|
||||
IsDownloading: true,
|
||||
Status: "downloading",
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
@@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) {
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesTotal = total
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) {
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) {
|
||||
item.Progress = 1.0
|
||||
item.IsDownloading = false
|
||||
item.Status = "completed"
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
||||
if bytesTotal > 0 {
|
||||
item.BytesTotal = bytesTotal
|
||||
}
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) {
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.Status = "finalizing"
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
delete(multiProgress.Items, itemID)
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func ClearAllItemProgress() {
|
||||
@@ -174,6 +200,7 @@ func ClearAllItemProgress() {
|
||||
defer multiMu.Unlock()
|
||||
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
markMultiProgressDirtyLocked()
|
||||
}
|
||||
|
||||
func setDownloadDir(path string) error {
|
||||
|
||||
+899
-47
File diff suppressed because it is too large
Load Diff
+306
-3
@@ -1,6 +1,98 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseQobuzURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantType string
|
||||
wantID string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "store album url",
|
||||
input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985",
|
||||
wantType: "album",
|
||||
wantID: "0886446451985",
|
||||
},
|
||||
{
|
||||
name: "store playlist url",
|
||||
input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430",
|
||||
wantType: "playlist",
|
||||
wantID: "2049430",
|
||||
},
|
||||
{
|
||||
name: "store artist url",
|
||||
input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886",
|
||||
wantType: "artist",
|
||||
wantID: "729886",
|
||||
},
|
||||
{
|
||||
name: "play track url",
|
||||
input: "https://play.qobuz.com/track/40681594",
|
||||
wantType: "track",
|
||||
wantID: "40681594",
|
||||
},
|
||||
{
|
||||
name: "custom scheme playlist url",
|
||||
input: "qobuzapp://playlist/2049430",
|
||||
wantType: "playlist",
|
||||
wantID: "2049430",
|
||||
},
|
||||
{
|
||||
name: "unsupported url",
|
||||
input: "https://example.com/not-qobuz",
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotType, gotID, err := parseQobuzURL(test.input)
|
||||
if test.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if gotType != test.wantType || gotID != test.wantID {
|
||||
t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzArtistAlbumIDs(t *testing.T) {
|
||||
body := []byte(`
|
||||
<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) {
|
||||
@@ -106,16 +198,56 @@ func TestGetQobuzDebugKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||
if err != nil {
|
||||
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
|
||||
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
|
||||
}
|
||||
if got := payload["quality"]; got != "hi-res" {
|
||||
t.Fatalf("payload quality = %v, want hi-res", got)
|
||||
}
|
||||
if got := payload["upload_to_r2"]; got != false {
|
||||
t.Fatalf("payload upload_to_r2 = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
body := []byte(`
|
||||
<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 {
|
||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
||||
if len(providers) != 5 {
|
||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"musicdl": qobuzAPIKindMusicDL,
|
||||
"dabmusic": qobuzAPIKindStandard,
|
||||
"deeb": qobuzAPIKindStandard,
|
||||
"qbz": qobuzAPIKindStandard,
|
||||
"squid": qobuzAPIKindStandard,
|
||||
}
|
||||
|
||||
for _, provider := range providers {
|
||||
@@ -133,3 +265,174 @@ func TestQobuzAvailableProviders(t *testing.T) {
|
||||
t.Fatalf("missing providers: %v", want)
|
||||
}
|
||||
}
|
||||
|
||||
func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
|
||||
track := &QobuzTrack{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Duration: duration,
|
||||
}
|
||||
track.Performer.Name = artist
|
||||
return track
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
GetTrackIDCache().Clear()
|
||||
})
|
||||
GetTrackIDCache().Clear()
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 111 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
if isrc != "TESTISRC1" {
|
||||
t.Fatalf("unexpected ISRC lookup: %q", isrc)
|
||||
}
|
||||
if expectedDurationSec != 180 {
|
||||
t.Fatalf("unexpected duration: %d", expectedDurationSec)
|
||||
}
|
||||
return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("metadata fallback should not run when ISRC fallback succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
if spotifyTrackID != "spotify-track-id" {
|
||||
t.Fatalf("unexpected spotify ID: %q", spotifyTrackID)
|
||||
}
|
||||
if isrc != "TESTISRC1" {
|
||||
t.Fatalf("unexpected SongLink ISRC: %q", isrc)
|
||||
}
|
||||
return &TrackAvailability{QobuzID: "111"}, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
ISRC: "TESTISRC1",
|
||||
SpotifyID: "spotify-track-id",
|
||||
TrackName: "Taste Back",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 180000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if track == nil || track.ID != 222 || track.Title != "Taste Back" {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
}
|
||||
|
||||
cached := GetTrackIDCache().Get(req.ISRC)
|
||||
if cached == nil || cached.QobuzTrackID != 222 {
|
||||
t.Fatalf("expected validated fallback track to be cached, got %+v", cached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
})
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 333 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("ISRC fallback should not run without an ISRC")
|
||||
return nil, nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
|
||||
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
|
||||
}
|
||||
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
QobuzID: "333",
|
||||
TrackName: "Taste Back",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 181000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) {
|
||||
origGetTrackByID := qobuzGetTrackByIDFunc
|
||||
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
|
||||
origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc
|
||||
origSongLinkCheck := songLinkCheckTrackAvailabilityFunc
|
||||
t.Cleanup(func() {
|
||||
qobuzGetTrackByIDFunc = origGetTrackByID
|
||||
qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata
|
||||
songLinkCheckTrackAvailabilityFunc = origSongLinkCheck
|
||||
})
|
||||
|
||||
qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) {
|
||||
if trackID != 40681594 {
|
||||
t.Fatalf("unexpected track ID lookup: %d", trackID)
|
||||
}
|
||||
return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil
|
||||
}
|
||||
qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("ISRC fallback should not run when request qobuz id succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("metadata fallback should not run when request qobuz id succeeds")
|
||||
return nil, nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||
t.Fatal("SongLink should not run when request qobuz id is provided")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
QobuzID: "qobuz:40681594",
|
||||
TrackName: "Sign of the Times",
|
||||
ArtistName: "Harry Styles",
|
||||
DurationMS: 341000,
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if track == nil || track.ID != 40681594 {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||
redacted := sanitizeSensitiveLogText(input)
|
||||
|
||||
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "[REDACTED]") {
|
||||
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||
}
|
||||
|
||||
blocked := []string{
|
||||
"http://accounts.example.com/oauth/authorize",
|
||||
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||
"https://localhost/oauth/authorize",
|
||||
}
|
||||
|
||||
for _, rawURL := range blocked {
|
||||
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||
t.Fatal("expected embedded URL credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
|
||||
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||
if err != nil {
|
||||
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||
}
|
||||
|
||||
if !isPathWithinBase(baseDir, destPath) {
|
||||
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(destPath)
|
||||
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||
}
|
||||
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||
}
|
||||
|
||||
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||
t.Fatal("expected empty extension id to be rejected")
|
||||
}
|
||||
}
|
||||
+144
-43
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -14,6 +15,10 @@ type SongLinkClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type songLinkPlatformLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
@@ -43,6 +48,7 @@ var (
|
||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
}
|
||||
songLinkRetryConfig = DefaultRetryConfig
|
||||
)
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
@@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
|
||||
if pageErr == nil {
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
if !songLinkRateLimiter.TryAcquire() {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
|
||||
}
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
@@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -154,10 +167,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
@@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
||||
req, err := http.NewRequest("GET", pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on song.link page")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
var pageData struct {
|
||||
Props struct {
|
||||
PageProps struct {
|
||||
PageData struct {
|
||||
Sections []struct {
|
||||
Links []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
Show bool `json:"show"`
|
||||
} `json:"links"`
|
||||
} `json:"sections"`
|
||||
} `json:"pageData"`
|
||||
} `json:"pageProps"`
|
||||
} `json:"props"`
|
||||
}
|
||||
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
|
||||
// Fallback to regular youtube if youtubeMusic not available
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||
for _, link := range section.Links {
|
||||
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||
continue
|
||||
}
|
||||
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
if len(linksByPlatform) == 0 {
|
||||
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
||||
}
|
||||
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
||||
}
|
||||
|
||||
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
||||
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
||||
const endMarker = `</script>`
|
||||
|
||||
start := bytes.Index(body, []byte(startMarker))
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
||||
}
|
||||
start += len(startMarker)
|
||||
|
||||
end := bytes.Index(body[start:], []byte(endMarker))
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
||||
}
|
||||
|
||||
return body[start : start+end], nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||
@@ -459,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||
@@ -542,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
@@ -647,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
@@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
if availability.SpotifyID == "" {
|
||||
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
}
|
||||
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
|
||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
parts := strings.Split(spotifyURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
@@ -802,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
if got := getRetryAfterDuration(resp); got != 0 {
|
||||
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "api.song.link":
|
||||
t.Fatalf("api.song.link should not be called when song.link page succeeds")
|
||||
return nil, nil
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 0,
|
||||
InitialDelay: 0,
|
||||
MaxDelay: 0,
|
||||
BackoffFactor: 1,
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
}()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("page failure")),
|
||||
Request: req,
|
||||
}, nil
|
||||
case req.URL.Host == "api.song.link":
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
|
||||
|
||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||
|
||||
@@ -157,6 +157,8 @@ type AlbumResponsePayload struct {
|
||||
}
|
||||
|
||||
type PlaylistInfoMetadata struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Images string `json:"images,omitempty"`
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
|
||||
+1043
-29
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTidalURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantType string
|
||||
wantID string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "track url",
|
||||
input: "https://tidal.com/track/77616174",
|
||||
wantType: "track",
|
||||
wantID: "77616174",
|
||||
},
|
||||
{
|
||||
name: "browse album url",
|
||||
input: "https://listen.tidal.com/browse/album/77616169",
|
||||
wantType: "album",
|
||||
wantID: "77616169",
|
||||
},
|
||||
{
|
||||
name: "artist url",
|
||||
input: "https://www.tidal.com/artist/3852143",
|
||||
wantType: "artist",
|
||||
wantID: "3852143",
|
||||
},
|
||||
{
|
||||
name: "playlist url",
|
||||
input: "https://tidal.com/playlist/edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||
wantType: "playlist",
|
||||
wantID: "edf3b7d2-cb42-41d7-93c0-afa2a395521b",
|
||||
},
|
||||
{
|
||||
name: "unsupported host",
|
||||
input: "https://example.com/track/123",
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
gotType, gotID, err := parseTidalURL(test.input)
|
||||
if test.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if gotType != test.wantType || gotID != test.wantID {
|
||||
t.Fatalf("parseTidalURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTidalRequestTrackID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{input: "40681594", want: 40681594, ok: true},
|
||||
{input: "tidal:40681594", want: 40681594, ok: true},
|
||||
{input: " tidal:40681594 ", want: 40681594, ok: true},
|
||||
{input: "", want: 0, ok: false},
|
||||
{input: "tidal:not-a-number", want: 0, ok: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
got, ok := parseTidalRequestTrackID(test.input)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Fatalf("parseTidalRequestTrackID(%q) = (%d, %v), want (%d, %v)", test.input, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalImageURL(t *testing.T) {
|
||||
got := tidalImageURL("fc18a64b-d76b-4582-962a-224cb05193f3", "1280x1280")
|
||||
want := "https://resources.tidal.com/images/fc18a64b/d76b/4582/962a/224cb05193f3/1280x1280.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("tidalImageURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalTrackToTrackMetadata(t *testing.T) {
|
||||
track := &TidalTrack{
|
||||
ID: 77616174,
|
||||
Title: "Bruckner: Symphony No. 5",
|
||||
ISRC: "GBUM71507433",
|
||||
Duration: 1172,
|
||||
TrackNumber: 5,
|
||||
VolumeNumber: 1,
|
||||
URL: "http://www.tidal.com/track/77616174",
|
||||
}
|
||||
track.Artist.ID = 3852143
|
||||
track.Artist.Name = "Staatskapelle Berlin"
|
||||
track.Artists = []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Picture string `json:"picture"`
|
||||
}{
|
||||
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||
}
|
||||
track.Album.ID = 77616169
|
||||
track.Album.Title = "Bruckner: Symphonies 4-9"
|
||||
track.Album.Cover = "fc18a64b-d76b-4582-962a-224cb05193f3"
|
||||
track.Album.ReleaseDate = "2016-02-26"
|
||||
|
||||
got := tidalTrackToTrackMetadata(track)
|
||||
if got.SpotifyID != "tidal:77616174" {
|
||||
t.Fatalf("unexpected track ID: %q", got.SpotifyID)
|
||||
}
|
||||
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||
}
|
||||
if got.AlbumID != "tidal:77616169" {
|
||||
t.Fatalf("unexpected album ID: %q", got.AlbumID)
|
||||
}
|
||||
if got.ArtistID != "tidal:3852143" {
|
||||
t.Fatalf("unexpected artist ID: %q", got.ArtistID)
|
||||
}
|
||||
if got.Images == "" || got.ExternalURL != "https://www.tidal.com/track/77616174" {
|
||||
t.Fatalf("unexpected image/url: %q / %q", got.Images, got.ExternalURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalAlbumToArtistAlbum(t *testing.T) {
|
||||
album := &tidalPublicAlbum{
|
||||
ID: 77616169,
|
||||
Title: "Bruckner: Symphonies 4-9",
|
||||
Type: "ALBUM",
|
||||
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||
ReleaseDate: "2016-02-26",
|
||||
NumberOfTracks: 23,
|
||||
Artists: []tidalPublicArtist{
|
||||
{ID: 3852143, Name: "Staatskapelle Berlin", Type: "MAIN"},
|
||||
{ID: 12430, Name: "Daniel Barenboim", Type: "FEATURED"},
|
||||
},
|
||||
}
|
||||
|
||||
got := tidalAlbumToArtistAlbum(album)
|
||||
if got.ID != "tidal:77616169" {
|
||||
t.Fatalf("unexpected album ID: %q", got.ID)
|
||||
}
|
||||
if got.AlbumType != "album" {
|
||||
t.Fatalf("unexpected album type: %q", got.AlbumType)
|
||||
}
|
||||
if got.Artists != "Staatskapelle Berlin, Daniel Barenboim" {
|
||||
t.Fatalf("unexpected artists: %q", got.Artists)
|
||||
}
|
||||
if got.Images == "" {
|
||||
t.Fatalf("expected image URL, got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalAlbumToArtistAlbumWithFallbackType(t *testing.T) {
|
||||
album := &tidalPublicAlbum{
|
||||
ID: 490623904,
|
||||
Title: "LET 'EM KNOW",
|
||||
Cover: "fc18a64b-d76b-4582-962a-224cb05193f3",
|
||||
NumberOfTracks: 1,
|
||||
}
|
||||
|
||||
got := tidalAlbumToArtistAlbumWithType(album, "single")
|
||||
if got.AlbumType != "single" {
|
||||
t.Fatalf("unexpected fallback album type: %q", got.AlbumType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalArtistAlbumTypeFromModuleTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
title string
|
||||
want string
|
||||
}{
|
||||
{title: "Albums", want: "album"},
|
||||
{title: "EP & Singles", want: "single"},
|
||||
{title: "Compilations", want: "album"},
|
||||
{title: "Appears On", want: "album"},
|
||||
{title: "Unknown", want: ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
if got := tidalArtistAlbumTypeFromModuleTitle(test.title); got != test.want {
|
||||
t.Fatalf("tidalArtistAlbumTypeFromModuleTitle(%q) = %q, want %q", test.title, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalPlaylistImageUsesOrigin(t *testing.T) {
|
||||
got := tidalImageURL("e6b59fd3-6995-40f0-8a32-174db3a8f4f2", "origin")
|
||||
want := "https://resources.tidal.com/images/e6b59fd3/6995/40f0/8a32/174db3a8f4f2/origin.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected origin playlist image URL: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalPlaylistOwnerName(t *testing.T) {
|
||||
editorial := &tidalPublicPlaylist{Type: "EDITORIAL"}
|
||||
if got := tidalPlaylistOwnerName(editorial); got != "TIDAL" {
|
||||
t.Fatalf("unexpected editorial owner: %q", got)
|
||||
}
|
||||
|
||||
artist := &tidalPublicPlaylist{Type: "ARTIST"}
|
||||
if got := tidalPlaylistOwnerName(artist); got != "Artist" {
|
||||
t.Fatalf("unexpected artist owner: %q", got)
|
||||
}
|
||||
|
||||
user := &tidalPublicPlaylist{}
|
||||
user.Creator.Name = "djtest"
|
||||
if got := tidalPlaylistOwnerName(user); got != "djtest" {
|
||||
t.Fatalf("unexpected creator owner: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package gobackend
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||
@@ -33,6 +35,37 @@ func normalizeLooseTitle(title string) string {
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// normalizeLooseArtistName folds diacritics and common separators so artist
|
||||
// verification is resilient to variants like "Özkent" vs "Ozkent".
|
||||
func normalizeLooseArtistName(name string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(name))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
decomposed := norm.NFD.String(trimmed)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(decomposed))
|
||||
|
||||
for _, r := range decomposed {
|
||||
switch {
|
||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||
continue
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
// Drop remaining punctuation/symbols for loose artist matching.
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func hasAlphaNumericRunes(value string) bool {
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
@@ -68,3 +101,45 @@ func normalizeSymbolOnlyTitle(title string) string {
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ==================== Shared Track Verification ====================
|
||||
|
||||
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||
type resolvedTrackInfo struct {
|
||||
Title string
|
||||
ArtistName string
|
||||
Duration int // seconds
|
||||
}
|
||||
|
||||
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||
// the original download request. Returns true if the track is a plausible match.
|
||||
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||
logPrefix, req.ArtistName, resolved.ArtistName)
|
||||
return false
|
||||
}
|
||||
|
||||
if req.TrackName != "" && resolved.Title != "" &&
|
||||
!titlesMatch(req.TrackName, resolved.Title) {
|
||||
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
||||
logPrefix, req.TrackName, resolved.Title)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
||||
diff := expectedDurationSec - resolved.Duration
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 10 {
|
||||
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
||||
logPrefix, expectedDurationSec, resolved.Duration)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type YouTubeDownloader struct {
|
||||
@@ -30,6 +29,7 @@ var (
|
||||
type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||
@@ -38,7 +38,7 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
||||
youtubeOpusSupportedBitrates = []int{128, 256, 320}
|
||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||
)
|
||||
|
||||
@@ -82,7 +82,7 @@ type YouTubeDownloadResult struct {
|
||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||
youtubeDownloaderOnce.Do(func() {
|
||||
globalYouTubeDownloader = &YouTubeDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||
client: NewHTTPClientWithTimeout(DownloadTimeout),
|
||||
apiURL: "https://api.qwkuns.me",
|
||||
}
|
||||
})
|
||||
@@ -147,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
|
||||
switch normalizedRaw {
|
||||
case "opus_256", "opus256", "opus":
|
||||
return "opus", 256, YouTubeQualityOpus256
|
||||
case "opus_320", "opus320":
|
||||
return "opus", 320, YouTubeQualityOpus320
|
||||
case "opus_128", "opus128":
|
||||
return "opus", 128, YouTubeQualityOpus128
|
||||
case "mp3_320", "mp3320", "mp3", "":
|
||||
@@ -511,12 +513,10 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// /watch?v=
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// /embed/
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
@@ -524,7 +524,6 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// /v/
|
||||
if strings.Contains(parsed.Path, "/v/") {
|
||||
parts := strings.Split(parsed.Path, "/v/")
|
||||
if len(parts) >= 2 {
|
||||
|
||||
@@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T)
|
||||
|
||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||
if opusBitrate != 256 {
|
||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
||||
if opusBitrate != 320 {
|
||||
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
||||
}
|
||||
|
||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||
@@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
|
||||
if format != "opus" {
|
||||
t.Fatalf("expected opus format, got %s", format)
|
||||
}
|
||||
if bitrate != 320 {
|
||||
t.Fatalf("expected 320 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityOpus320 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +367,26 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTidalAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchQobuzAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
@@ -383,6 +403,22 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getQobuzMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
let resourceId = args["resource_id"] as! String
|
||||
let response = GobackendGetQobuzMetadata(resourceType, resourceId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getTidalMetadata":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let resourceType = args["resource_type"] as! String
|
||||
let resourceId = args["resource_id"] as! String
|
||||
let response = GobackendGetTidalMetadata(resourceType, resourceId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseDeezerUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
@@ -390,6 +426,13 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseQobuzUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendParseQobuzURLExport(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "parseTidalUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
@@ -600,6 +643,20 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTracksWithMetadataProviders":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let limit = args["limit"] as? Int ?? 20
|
||||
let includeExtensions = args["include_extensions"] as? Bool ?? true
|
||||
let response = GobackendSearchTracksWithMetadataProvidersJSON(
|
||||
query,
|
||||
Int(limit),
|
||||
includeExtensions,
|
||||
&error
|
||||
)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "enrichTrackWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -791,6 +848,23 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "setStoreRegistryUrl":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let registryUrl = args["registry_url"] as? String ?? ""
|
||||
GobackendSetStoreRegistryURLJSON(registryUrl, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getStoreRegistryUrl":
|
||||
let response = GobackendGetStoreRegistryURLJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "clearStoreRegistryUrl":
|
||||
GobackendClearStoreRegistryURLJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getStoreExtensions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.7.2';
|
||||
static const String buildNumber = '105';
|
||||
static const String version = '3.9.0';
|
||||
static const String buildNumber = '115';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
static const String appName = 'SpotiFLAC';
|
||||
static const String copyright = '© 2026 SpotiFLAC';
|
||||
|
||||
|
||||
static const String mobileAuthor = 'zarzet';
|
||||
static const String originalAuthor = 'afkarxyz';
|
||||
|
||||
|
||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String originalGithubUrl =
|
||||
'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ abstract class AppLocalizations {
|
||||
/// **'Filename Format'**
|
||||
String get downloadFilenameFormat;
|
||||
|
||||
/// Setting for folder structure
|
||||
/// Title of the folder organization picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Folder Organization'**
|
||||
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
|
||||
/// **'Import'**
|
||||
String get dialogImport;
|
||||
|
||||
/// Confirm button in Download All dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download'**
|
||||
String get dialogDownload;
|
||||
|
||||
/// Dialog button - discard changes
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2236,10 +2242,88 @@ abstract class AppLocalizations {
|
||||
/// **'Clear filters'**
|
||||
String get storeClearFilters;
|
||||
|
||||
/// Store setup screen - heading when no repo is configured
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add Extension Repository'**
|
||||
String get storeAddRepoTitle;
|
||||
|
||||
/// Store setup screen - explanatory text
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.'**
|
||||
String get storeAddRepoDescription;
|
||||
|
||||
/// Label for the repository URL input field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Repository URL'**
|
||||
String get storeRepoUrlLabel;
|
||||
|
||||
/// Hint/placeholder for the repository URL input field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'https://github.com/user/repo'**
|
||||
String get storeRepoUrlHint;
|
||||
|
||||
/// Helper text below the repository URL input field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'e.g. https://github.com/user/extensions-repo'**
|
||||
String get storeRepoUrlHelper;
|
||||
|
||||
/// Button to submit a new repository URL
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add Repository'**
|
||||
String get storeAddRepoButton;
|
||||
|
||||
/// Tooltip for the change-repository icon button in the app bar
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Change repository'**
|
||||
String get storeChangeRepoTooltip;
|
||||
|
||||
/// Title of the change/remove repository dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extension Repository'**
|
||||
String get storeRepoDialogTitle;
|
||||
|
||||
/// Label shown above the current repository URL in the dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Current repository:'**
|
||||
String get storeRepoDialogCurrent;
|
||||
|
||||
/// Label for the new repository URL field inside the dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New Repository URL'**
|
||||
String get storeNewRepoUrlLabel;
|
||||
|
||||
/// Error heading when the store cannot be loaded
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to load store'**
|
||||
String get storeLoadError;
|
||||
|
||||
/// Message when store has no extensions
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No extensions available'**
|
||||
String get storeEmptyNoExtensions;
|
||||
|
||||
/// Message when search/filter returns no results
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No extensions found'**
|
||||
String get storeEmptyNoResults;
|
||||
|
||||
/// Default search provider option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default (Deezer/Spotify)'**
|
||||
/// **'Default (Deezer)'**
|
||||
String get extensionDefaultProvider;
|
||||
|
||||
/// Subtitle for default provider
|
||||
@@ -2512,6 +2596,66 @@ abstract class AppLocalizations {
|
||||
/// **'24-bit / up to 192kHz'**
|
||||
String get qualityHiResFlacMaxSubtitle;
|
||||
|
||||
/// Quality option label for Tidal lossy 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps'**
|
||||
String get downloadLossy320;
|
||||
|
||||
/// Setting title to pick output format for Tidal lossy downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy Format'**
|
||||
String get downloadLossyFormat;
|
||||
|
||||
/// Title of the Tidal lossy format picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps Format'**
|
||||
String get downloadLossy320Format;
|
||||
|
||||
/// Description in the Tidal lossy format picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
|
||||
String get downloadLossy320FormatDesc;
|
||||
|
||||
/// Tidal lossy format option - MP3 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3 320kbps'**
|
||||
String get downloadLossyMp3;
|
||||
|
||||
/// Subtitle for MP3 320kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best compatibility, ~10MB per track'**
|
||||
String get downloadLossyMp3Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 256kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 256kbps'**
|
||||
String get downloadLossyOpus256;
|
||||
|
||||
/// Subtitle for Opus 256kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best quality Opus, ~8MB per track'**
|
||||
String get downloadLossyOpus256Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 128kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 128kbps'**
|
||||
String get downloadLossyOpus128;
|
||||
|
||||
/// Subtitle for Opus 128kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smallest size, ~4MB per track'**
|
||||
String get downloadLossyOpus128Subtitle;
|
||||
|
||||
/// Note about quality availability
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3022,6 +3166,42 @@ abstract class AppLocalizations {
|
||||
/// **'Show when searching for existing tracks'**
|
||||
String get libraryShowDuplicateIndicatorSubtitle;
|
||||
|
||||
/// Setting for automatic library scanning
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto Scan'**
|
||||
String get libraryAutoScan;
|
||||
|
||||
/// Subtitle for auto scan setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Automatically scan your library for new files'**
|
||||
String get libraryAutoScanSubtitle;
|
||||
|
||||
/// Auto scan disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Off'**
|
||||
String get libraryAutoScanOff;
|
||||
|
||||
/// Auto scan when app opens
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every app open'**
|
||||
String get libraryAutoScanOnOpen;
|
||||
|
||||
/// Auto scan once per day
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Daily'**
|
||||
String get libraryAutoScanDaily;
|
||||
|
||||
/// Auto scan once per week
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weekly'**
|
||||
String get libraryAutoScanWeekly;
|
||||
|
||||
/// Section header for library actions
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3754,6 +3934,36 @@ abstract class AppLocalizations {
|
||||
/// **'FFmpeg metadata embed failed'**
|
||||
String get trackReEnrichFfmpegFailed;
|
||||
|
||||
/// Action/button label for queueing FLAC redownloads for local tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Queue FLAC'**
|
||||
String get queueFlacAction;
|
||||
|
||||
/// Confirmation dialog body before queueing FLAC redownloads for local tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
|
||||
String queueFlacConfirmMessage(int count);
|
||||
|
||||
/// Snackbar while resolving remote matches for local FLAC redownloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Finding FLAC matches... ({current}/{total})'**
|
||||
String queueFlacFindingProgress(int current, int total);
|
||||
|
||||
/// Snackbar when no safe FLAC redownload matches were found
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No reliable online matches found for the selection'**
|
||||
String get queueFlacNoReliableMatches;
|
||||
|
||||
/// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
|
||||
|
||||
/// Snackbar when save operation fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -3769,7 +3979,7 @@ abstract class AppLocalizations {
|
||||
/// Subtitle for convert format menu item
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert to MP3 or Opus'**
|
||||
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
|
||||
String get trackConvertFormatSubtitle;
|
||||
|
||||
/// Title of convert bottom sheet
|
||||
@@ -3806,6 +4016,21 @@ abstract class AppLocalizations {
|
||||
String bitrate,
|
||||
);
|
||||
|
||||
/// Confirmation dialog message for lossless-to-lossless conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
);
|
||||
|
||||
/// Hint shown when converting between lossless formats
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossless conversion — no quality loss'**
|
||||
String get trackConvertLosslessHint;
|
||||
|
||||
/// Snackbar while converting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4176,6 +4401,12 @@ abstract class AppLocalizations {
|
||||
String bitrate,
|
||||
);
|
||||
|
||||
/// Confirmation dialog message for lossless batch conversion
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format);
|
||||
|
||||
/// Snackbar during batch conversion progress
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4205,6 +4436,654 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist folders use Track Artist only'**
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
|
||||
|
||||
/// Title for the lyrics provider priority page
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics Providers'**
|
||||
String get lyricsProvidersTitle;
|
||||
|
||||
/// Description on the lyrics provider priority page
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.'**
|
||||
String get lyricsProvidersDescription;
|
||||
|
||||
/// Info tip on lyrics provider priority page
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.'**
|
||||
String get lyricsProvidersInfoText;
|
||||
|
||||
/// Section header for enabled providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enabled ({count})'**
|
||||
String lyricsProvidersEnabledSection(int count);
|
||||
|
||||
/// Section header for disabled providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disabled ({count})'**
|
||||
String lyricsProvidersDisabledSection(int count);
|
||||
|
||||
/// Snackbar when user tries to disable the last enabled provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'At least one provider must remain enabled'**
|
||||
String get lyricsProvidersAtLeastOne;
|
||||
|
||||
/// Snackbar after saving lyrics provider priority
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lyrics provider priority saved'**
|
||||
String get lyricsProvidersSaved;
|
||||
|
||||
/// Body text of the discard-changes dialog on lyrics provider page
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You have unsaved changes that will be lost.'**
|
||||
String get lyricsProvidersDiscardContent;
|
||||
|
||||
/// Description for Spotify Lyrics API provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spotify-sourced synced lyrics via community API'**
|
||||
String get lyricsProviderSpotifyApiDesc;
|
||||
|
||||
/// Description for LRCLIB provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open-source synced lyrics database'**
|
||||
String get lyricsProviderLrclibDesc;
|
||||
|
||||
/// Description for Netease provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'NetEase Cloud Music (good for Asian songs)'**
|
||||
String get lyricsProviderNeteaseDesc;
|
||||
|
||||
/// Description for Musixmatch provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Largest lyrics database (multi-language)'**
|
||||
String get lyricsProviderMusixmatchDesc;
|
||||
|
||||
/// Description for Apple Music provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Word-by-word synced lyrics (via proxy)'**
|
||||
String get lyricsProviderAppleMusicDesc;
|
||||
|
||||
/// Description for QQ Music provider
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'QQ Music (good for Chinese songs, via proxy)'**
|
||||
String get lyricsProviderQqMusicDesc;
|
||||
|
||||
/// Generic description for extension-based lyrics providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extension provider'**
|
||||
String get lyricsProviderExtensionDesc;
|
||||
|
||||
/// Title of SAF migration dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Storage Update Required'**
|
||||
String get safMigrationTitle;
|
||||
|
||||
/// First paragraph of SAF migration dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.'**
|
||||
String get safMigrationMessage1;
|
||||
|
||||
/// Second paragraph of SAF migration dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Please select your download folder again to switch to the new storage system.'**
|
||||
String get safMigrationMessage2;
|
||||
|
||||
/// Snackbar after successfully migrating to SAF
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download folder updated to SAF mode'**
|
||||
String get safMigrationSuccess;
|
||||
|
||||
/// Settings menu item - donate
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Donate'**
|
||||
String get settingsDonate;
|
||||
|
||||
/// Subtitle for donate menu item
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Support SpotiFLAC-Mobile development'**
|
||||
String get settingsDonateSubtitle;
|
||||
|
||||
/// Tooltip for the Love All button on album/playlist screens
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Love All'**
|
||||
String get tooltipLoveAll;
|
||||
|
||||
/// Tooltip for the Add to Playlist button
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add to Playlist'**
|
||||
String get tooltipAddToPlaylist;
|
||||
|
||||
/// Snackbar after removing multiple tracks from Loved folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Removed {count} tracks from Loved'**
|
||||
String snackbarRemovedTracksFromLoved(int count);
|
||||
|
||||
/// Snackbar after adding multiple tracks to Loved folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Added {count} tracks to Loved'**
|
||||
String snackbarAddedTracksToLoved(int count);
|
||||
|
||||
/// Dialog title for bulk download confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download All'**
|
||||
String get dialogDownloadAllTitle;
|
||||
|
||||
/// Body of the Download All confirmation dialog
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download {count} tracks?'**
|
||||
String dialogDownloadAllMessage(int count);
|
||||
|
||||
/// Checkbox label in import dialog to skip already-downloaded songs
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Skip already downloaded songs'**
|
||||
String get homeSkipAlreadyDownloaded;
|
||||
|
||||
/// Context menu item to navigate to the album page
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Go to Album'**
|
||||
String get homeGoToAlbum;
|
||||
|
||||
/// Snackbar when album info cannot be loaded
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album info not available'**
|
||||
String get homeAlbumInfoUnavailable;
|
||||
|
||||
/// Snackbar while loading a CUE sheet file
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Loading CUE sheet...'**
|
||||
String get snackbarLoadingCueSheet;
|
||||
|
||||
/// Snackbar after successfully saving track metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Metadata saved successfully'**
|
||||
String get snackbarMetadataSaved;
|
||||
|
||||
/// Snackbar when lyrics embedding fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to embed lyrics'**
|
||||
String get snackbarFailedToEmbedLyrics;
|
||||
|
||||
/// Snackbar when writing metadata back to file fails
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Failed to write back to storage'**
|
||||
String get snackbarFailedToWriteStorage;
|
||||
|
||||
/// Generic error snackbar with error detail
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Error: {error}'**
|
||||
String snackbarError(String error);
|
||||
|
||||
/// Snackbar when an extension button has no action configured
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No action defined for this button'**
|
||||
String get snackbarNoActionDefined;
|
||||
|
||||
/// Empty state message when an album has no tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No tracks found for this album'**
|
||||
String get noTracksFoundForAlbum;
|
||||
|
||||
/// Subtitle text in Android download location bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose storage mode for downloaded files.'**
|
||||
String get downloadLocationSubtitle;
|
||||
|
||||
/// Storage mode option - use legacy app folder
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'App folder (non-SAF)'**
|
||||
String get storageModeAppFolder;
|
||||
|
||||
/// Subtitle for app folder storage mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Use default Music/SpotiFLAC path'**
|
||||
String get storageModeAppFolderSubtitle;
|
||||
|
||||
/// Storage mode option - use Android SAF picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SAF folder'**
|
||||
String get storageModeSaf;
|
||||
|
||||
/// Subtitle for SAF storage mode
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pick folder via Android Storage Access Framework'**
|
||||
String get storageModeSafSubtitle;
|
||||
|
||||
/// Description text in filename format bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Customize how your files are named.'**
|
||||
String get downloadFilenameDescription;
|
||||
|
||||
/// Label above filename tag chips
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap to insert tag:'**
|
||||
String get downloadFilenameInsertTag;
|
||||
|
||||
/// Subtitle when separate singles folder is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Albums/ and Singles/ folders'**
|
||||
String get downloadSeparateSinglesEnabled;
|
||||
|
||||
/// Subtitle when separate singles folder is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All files in same structure'**
|
||||
String get downloadSeparateSinglesDisabled;
|
||||
|
||||
/// Setting title for artist folder filter options
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist Name Filters'**
|
||||
String get downloadArtistNameFilters;
|
||||
|
||||
/// Setting title for adding a playlist folder prefix before the normal organization structure
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create playlist source folder'**
|
||||
String get downloadCreatePlaylistSourceFolder;
|
||||
|
||||
/// Subtitle when playlist source folder prefix is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist downloads use Playlist/ plus your normal folder structure.'**
|
||||
String get downloadCreatePlaylistSourceFolderEnabled;
|
||||
|
||||
/// Subtitle when playlist source folder prefix is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist downloads use the normal folder structure only.'**
|
||||
String get downloadCreatePlaylistSourceFolderDisabled;
|
||||
|
||||
/// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By Playlist already places downloads inside a playlist folder.'**
|
||||
String get downloadCreatePlaylistSourceFolderRedundant;
|
||||
|
||||
/// Setting title for SongLink country region
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'SongLink Region'**
|
||||
String get downloadSongLinkRegion;
|
||||
|
||||
/// Setting title for network compatibility toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Network compatibility mode'**
|
||||
String get downloadNetworkCompatibilityMode;
|
||||
|
||||
/// Subtitle when network compatibility mode is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enabled: try HTTP + accept invalid TLS certificates (unsafe)'**
|
||||
String get downloadNetworkCompatibilityModeEnabled;
|
||||
|
||||
/// Subtitle when network compatibility mode is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Off: strict HTTPS certificate validation (recommended)'**
|
||||
String get downloadNetworkCompatibilityModeDisabled;
|
||||
|
||||
/// Hint shown instead of Ask-quality subtitle when no built-in service selected
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select a built-in service to enable'**
|
||||
String get downloadSelectServiceToEnable;
|
||||
|
||||
/// Info hint when non-Tidal/Qobuz service is selected
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select Tidal or Qobuz above to configure quality'**
|
||||
String get downloadSelectTidalQobuz;
|
||||
|
||||
/// Subtitle for Embed Lyrics when Embed Metadata is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disabled while Embed Metadata is turned off'**
|
||||
String get downloadEmbedLyricsDisabled;
|
||||
|
||||
/// Toggle title for including Netease translated lyrics
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Netease: Include Translation'**
|
||||
String get downloadNeteaseIncludeTranslation;
|
||||
|
||||
/// Subtitle when Netease translation is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Append translated lyrics when available'**
|
||||
String get downloadNeteaseIncludeTranslationEnabled;
|
||||
|
||||
/// Subtitle when Netease translation is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Use original lyrics only'**
|
||||
String get downloadNeteaseIncludeTranslationDisabled;
|
||||
|
||||
/// Toggle title for including Netease romanized lyrics
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Netease: Include Romanization'**
|
||||
String get downloadNeteaseIncludeRomanization;
|
||||
|
||||
/// Subtitle when Netease romanization is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Append romanized lyrics when available'**
|
||||
String get downloadNeteaseIncludeRomanizationEnabled;
|
||||
|
||||
/// Subtitle when Netease romanization is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disabled'**
|
||||
String get downloadNeteaseIncludeRomanizationDisabled;
|
||||
|
||||
/// Toggle title for Apple/QQ multi-person word-by-word lyrics
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apple/QQ Multi-Person Word-by-Word'**
|
||||
String get downloadAppleQqMultiPerson;
|
||||
|
||||
/// Subtitle when multi-person word-by-word is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enable v1/v2 speaker and [bg:] tags'**
|
||||
String get downloadAppleQqMultiPersonEnabled;
|
||||
|
||||
/// Subtitle when multi-person word-by-word is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Simplified word-by-word formatting'**
|
||||
String get downloadAppleQqMultiPersonDisabled;
|
||||
|
||||
/// Setting title for Musixmatch language preference
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Musixmatch Language'**
|
||||
String get downloadMusixmatchLanguage;
|
||||
|
||||
/// Option label when Musixmatch uses original language
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto (original)'**
|
||||
String get downloadMusixmatchLanguageAuto;
|
||||
|
||||
/// Toggle title for filtering contributing artists in Album Artist metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filter contributing artists in Album Artist'**
|
||||
String get downloadFilterContributing;
|
||||
|
||||
/// Subtitle when contributing artist filter is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album Artist metadata uses primary artist only'**
|
||||
String get downloadFilterContributingEnabled;
|
||||
|
||||
/// Subtitle when contributing artist filter is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep full Album Artist metadata value'**
|
||||
String get downloadFilterContributingDisabled;
|
||||
|
||||
/// Subtitle for lyrics providers setting when no providers are enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None enabled'**
|
||||
String get downloadProvidersNoneEnabled;
|
||||
|
||||
/// Label for the Musixmatch language code text field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Language code'**
|
||||
String get downloadMusixmatchLanguageCode;
|
||||
|
||||
/// Hint text for the Musixmatch language code field
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'auto / en / es / ja'**
|
||||
String get downloadMusixmatchLanguageHint;
|
||||
|
||||
/// Description in the Musixmatch language picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set preferred language code (example: en, es, ja). Leave empty for auto.'**
|
||||
String get downloadMusixmatchLanguageDesc;
|
||||
|
||||
/// Button to reset Musixmatch language to automatic
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto'**
|
||||
String get downloadMusixmatchAuto;
|
||||
|
||||
/// Subtitle for 'Any' network mode option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'WiFi + Mobile Data'**
|
||||
String get downloadNetworkAnySubtitle;
|
||||
|
||||
/// Subtitle for 'WiFi only' network mode option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pause downloads on mobile data'**
|
||||
String get downloadNetworkWifiOnlySubtitle;
|
||||
|
||||
/// Description in the SongLink region picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Used as userCountry for SongLink API lookup.'**
|
||||
String get downloadSongLinkRegionDesc;
|
||||
|
||||
/// Snackbar when the audio format is not supported for the requested operation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unsupported audio format'**
|
||||
String get snackbarUnsupportedAudioFormat;
|
||||
|
||||
/// Tooltip for refresh button on cache management page
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refresh'**
|
||||
String get cacheRefresh;
|
||||
|
||||
/// Dialog message for bulk playlist download confirmation
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
|
||||
|
||||
/// Button label for bulk downloading selected playlists
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
|
||||
String bulkDownloadPlaylistsButton(int count);
|
||||
|
||||
/// Button label when no playlists are selected for download
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select playlists to download'**
|
||||
String get bulkDownloadSelectPlaylists;
|
||||
|
||||
/// Snackbar when selected playlists contain no tracks
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Selected playlists have no tracks'**
|
||||
String get snackbarSelectedPlaylistsEmpty;
|
||||
|
||||
/// Playlist count display
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
|
||||
String playlistsCount(int count);
|
||||
|
||||
/// Section title for selective online metadata auto-fill in the edit metadata sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Auto-fill from online'**
|
||||
String get editMetadataAutoFill;
|
||||
|
||||
/// Description for the auto-fill section
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select fields to fill automatically from online metadata'**
|
||||
String get editMetadataAutoFillDesc;
|
||||
|
||||
/// Button label to fetch online metadata and fill selected fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetch & Fill'**
|
||||
String get editMetadataAutoFillFetch;
|
||||
|
||||
/// Snackbar shown while searching for online metadata
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Searching online...'**
|
||||
String get editMetadataAutoFillSearching;
|
||||
|
||||
/// Snackbar when online metadata search returns no results
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No matching metadata found online'**
|
||||
String get editMetadataAutoFillNoResults;
|
||||
|
||||
/// Snackbar confirming how many fields were auto-filled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
|
||||
String editMetadataAutoFillDone(int count);
|
||||
|
||||
/// Snackbar when user taps Fetch without selecting any fields
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select at least one field to auto-fill'**
|
||||
String get editMetadataAutoFillNoneSelected;
|
||||
|
||||
/// Chip label for title field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Title'**
|
||||
String get editMetadataFieldTitle;
|
||||
|
||||
/// Chip label for artist field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Artist'**
|
||||
String get editMetadataFieldArtist;
|
||||
|
||||
/// Chip label for album field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album'**
|
||||
String get editMetadataFieldAlbum;
|
||||
|
||||
/// Chip label for album artist field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Album Artist'**
|
||||
String get editMetadataFieldAlbumArtist;
|
||||
|
||||
/// Chip label for date field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Date'**
|
||||
String get editMetadataFieldDate;
|
||||
|
||||
/// Chip label for track number field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Track #'**
|
||||
String get editMetadataFieldTrackNum;
|
||||
|
||||
/// Chip label for disc number field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disc #'**
|
||||
String get editMetadataFieldDiscNum;
|
||||
|
||||
/// Chip label for genre field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Genre'**
|
||||
String get editMetadataFieldGenre;
|
||||
|
||||
/// Chip label for ISRC field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ISRC'**
|
||||
String get editMetadataFieldIsrc;
|
||||
|
||||
/// Chip label for label field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Label'**
|
||||
String get editMetadataFieldLabel;
|
||||
|
||||
/// Chip label for copyright field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Copyright'**
|
||||
String get editMetadataFieldCopyright;
|
||||
|
||||
/// Chip label for cover art field in auto-fill selector
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cover Art'**
|
||||
String get editMetadataFieldCover;
|
||||
|
||||
/// Button to select all fields for auto-fill
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All'**
|
||||
String get editMetadataSelectAll;
|
||||
|
||||
/// Button to select only fields that are currently empty
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Empty only'**
|
||||
String get editMetadataSelectEmpty;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Alben';
|
||||
@@ -441,7 +441,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get setupDownloadLocationIosMessage =>
|
||||
'Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
|
||||
'Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
|
||||
|
||||
@override
|
||||
String get setupAppDocumentsFolder => 'App-Dokumentenordner';
|
||||
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Importieren';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Verwerfen';
|
||||
|
||||
@@ -702,15 +705,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get errorNoTracksFound => 'Keine Titel gefunden';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
'Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
'Laden fehlgeschlagen. Bitte erneut versuchen.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
@@ -747,7 +750,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get selectionAllSelected => 'Alle Titel sind ausgewählt';
|
||||
|
||||
@override
|
||||
String get selectionSelectToDelete => 'Titel zum Löschen auswählen';
|
||||
String get selectionSelectToDelete => 'Titel zum Löschen wählen';
|
||||
|
||||
@override
|
||||
String progressFetchingMetadata(int current, int total) {
|
||||
@@ -764,7 +767,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get searchArtists => 'Künstler';
|
||||
|
||||
@override
|
||||
String get searchAlbums => 'Albums';
|
||||
String get searchAlbums => 'Alben';
|
||||
|
||||
@override
|
||||
String get searchPlaylists => 'Playlisten';
|
||||
@@ -786,11 +789,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get folderOrganizationNone => 'Keine Organisation';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'Nach Playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Ordner für jede Playlist trennen';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'Nach Künstler';
|
||||
@@ -807,7 +810,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get folderOrganizationNoneSubtitle =>
|
||||
'Alle Dateien im Download-Verzeichnis';
|
||||
'Alle Dateien im Download-Ordner';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtistSubtitle =>
|
||||
@@ -1215,6 +1218,47 @@ 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)';
|
||||
|
||||
@@ -1369,6 +1413,38 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
|
||||
@@ -1387,19 +1463,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
||||
|
||||
@override
|
||||
String get downloadDirectory => 'Downloadverzeichnis';
|
||||
String get downloadDirectory => 'Download-Ordner';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesFolder => 'Singles Ordner trennen';
|
||||
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Album-Künstler für Ordner verwenden';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
String get downloadUsePrimaryArtistOnly => 'Primärer Künstler nur für Ordner';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
@@ -1407,7 +1484,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
'Vollständiger Künstler für Ordnername';
|
||||
|
||||
@override
|
||||
String get downloadSelectQuality => 'Qualität wählen';
|
||||
@@ -1429,7 +1506,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Bist du dir sicher, dass du alle Downloads löschen möchten?';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
String get settingsAutoExportFailed =>
|
||||
'Auto-Export fehlgeschlagener Downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
@@ -1452,14 +1530,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get albumFolderArtistAlbum => 'Künstler/Album';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
|
||||
String get albumFolderArtistAlbumSubtitle => 'Alben/Künster Name/Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
|
||||
String get albumFolderArtistYearAlbum => 'Künstler / [Year] Album';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistYearAlbumSubtitle =>
|
||||
'Albums/Künster Name/[2005] Album Name/';
|
||||
'Alben/Künster Name/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderAlbumOnly => 'Nur Alben';
|
||||
@@ -1471,14 +1549,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get albumFolderYearAlbum => '[Year] Album';
|
||||
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
String get albumFolderYearAlbumSubtitle => 'Alben/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
String get albumFolderArtistAlbumSingles => 'Künstler / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
'Künstler/Album/ und Künstler/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
||||
@@ -1517,7 +1595,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
String get downloadedAlbumSelectToDelete => 'Titel zum Löschen wählen';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
@@ -1563,7 +1641,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count Titel von $albumCount Albums';
|
||||
return '$count Titel aus $albumCount Alben';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1579,14 +1657,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
'Wähle bestimmte Alben oder Singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Lade Titel...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
return 'Lade $current von $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1599,7 +1677,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
return '$count Titel zur Warteschlange hinzugefügt';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1611,7 +1689,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get discographyNoAlbums => 'Es sind keine Alben verfügbar';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
String get discographyFailedToFetch => 'Fehler beim Abrufen einiger Alben';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Speicherzugriff';
|
||||
@@ -1620,14 +1698,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get allFilesAccess => 'Zugriff auf alle Dateien';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
String get allFilesAccessEnabledSubtitle => 'Darf in jeden Ordner schreiben';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
String get allFilesAccessDisabledSubtitle => 'Nur auf Medienordner begrenzt';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.';
|
||||
'Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
@@ -1641,13 +1719,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get settingsLocalLibrary => 'Lokale Bibliothek';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
String get settingsLocalLibrarySubtitle =>
|
||||
'Musik scannen & Duplikate erkennen';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Speicher & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
String get settingsCacheSubtitle =>
|
||||
'Größe anzeigen und Daten im Cache leeren';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Lokale Bibliothek';
|
||||
@@ -1660,7 +1740,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
'Scan und verfolge deine bestehende Musik';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Bibliotheksordner';
|
||||
@@ -1669,12 +1749,31 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get libraryFolderHint => 'Tippe um Ordner auszuwählen';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
String get libraryShowDuplicateIndicator => 'Duplikat Indikator anzeigen';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Bei der Suche nach vorhandenen Titeln anzeigen';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Aktionen';
|
||||
|
||||
@@ -1851,7 +1950,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik';
|
||||
'Hole dir FLAC Audio von Tidal, Qobuz oder Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -1918,7 +2017,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Downloadverzeichnis und Ordnerorganisation ändern';
|
||||
'Download-Ordner und Ordner-Organisation ändern';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
@@ -1976,14 +2075,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get cacheSectionMaintenance => 'Wartung';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App-Cache Verzeichnis';
|
||||
String get cacheAppDirectory => 'App-Cache Ordner';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporäres Verzeichnis';
|
||||
String get cacheTempDirectory => 'Temporärer Ordner';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
@@ -2104,11 +2203,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
return 'Cover in $fileName gespeichert';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
String get trackCoverNoSource => 'Keine Cover Quelle vorhanden';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
@@ -2128,6 +2227,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Fehler: $error';
|
||||
@@ -2160,6 +2281,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Konvertiere Audio...';
|
||||
|
||||
@@ -2172,10 +2305,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackConvertFailed => 'Konvertierung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => 'CUE-Sheet aufteilen';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
String get cueSplitSubtitle => 'CUE+FLAC in einzelne Titel aufteilen';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
@@ -2184,40 +2317,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
return 'Künstler: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
return '$count Titel';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
String get cueSplitConfirmTitle => 'CUE-Album aufteilen';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
return 'Soll „$album“ in $count einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
return 'CUE-Sheet wird geteilt... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
return '$count Titel erfolgreich aufgeteilt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
String get cueSplitFailed => 'CUE-Aufteilung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
String get cueSplitNoAudioFile =>
|
||||
'Audiodatei für dieses CUE-Sheet nicht gefunden';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
String get cueSplitButton => 'In Titel aufteilen';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Erstellen';
|
||||
@@ -2414,6 +2548,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Konvertiere $current von $total...';
|
||||
@@ -2431,9 +2576,423 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar';
|
||||
'Interpret-Ordner verwenden Album-Interpret, sofern vorhanden';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Künstler-Ordner nur für Titel-Künstler';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lyricsProvidersAtLeastOne =>
|
||||
'At least one provider must remain enabled';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToLoved(int count) {
|
||||
return 'Added $count tracks to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1196,7 +1199,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get storeClearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load store';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
@@ -1345,6 +1389,38 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1651,6 +1727,25 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -2101,6 +2196,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2110,7 +2227,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2133,6 +2251,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2386,6 +2516,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2408,4 +2549,418 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lyricsProvidersAtLeastOne =>
|
||||
'At least one provider must remain enabled';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToLoved(int count) {
|
||||
return 'Added $count tracks to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
+2986
-2137
File diff suppressed because it is too large
Load Diff
@@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1197,6 +1200,47 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get storeClearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load store';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
|
||||
@@ -1347,6 +1391,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1653,6 +1729,25 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1829,7 +1924,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2103,6 +2198,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2135,6 +2252,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2388,6 +2517,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2410,4 +2550,418 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lyricsProvidersAtLeastOne =>
|
||||
'At least one provider must remain enabled';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToLoved(int count) {
|
||||
return 'Added $count tracks to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1195,6 +1198,47 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get storeClearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load store';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
|
||||
@@ -1345,6 +1389,38 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1651,6 +1727,25 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1827,7 +1922,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2101,6 +2196,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2133,6 +2250,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2386,6 +2515,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2408,4 +2548,418 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lyricsProvidersAtLeastOne =>
|
||||
'At least one provider must remain enabled';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToLoved(int count) {
|
||||
return 'Added $count tracks to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
||||
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Album';
|
||||
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Impor';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Buang';
|
||||
|
||||
@@ -766,21 +769,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get filenameFormat => 'Format Nama File';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
'Aktifkan tag yang diformat untuk padding trek dan pola tanggal';
|
||||
|
||||
@override
|
||||
String get folderOrganizationNone => 'Tidak ada';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'Berdasarkan Daftar Putar';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Setiap daftar putar memerlukan folder terpisah';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'Berdasarkan Artis';
|
||||
@@ -936,13 +939,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
'Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.';
|
||||
|
||||
@override
|
||||
String get credentialsClientId => 'Client ID';
|
||||
String get credentialsClientId => 'ID Klien';
|
||||
|
||||
@override
|
||||
String get credentialsClientIdHint => 'Tempel Client ID';
|
||||
|
||||
@override
|
||||
String get credentialsClientSecret => 'Client Secret';
|
||||
String get credentialsClientSecret => 'Rahasia Klien';
|
||||
|
||||
@override
|
||||
String get credentialsClientSecretHint => 'Tempel Client Secret';
|
||||
@@ -951,7 +954,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get channelStable => 'Stabil';
|
||||
|
||||
@override
|
||||
String get channelPreview => 'Preview';
|
||||
String get channelPreview => 'Pratinjau';
|
||||
|
||||
@override
|
||||
String get sectionSearchSource => 'Sumber Pencarian';
|
||||
@@ -981,33 +984,34 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get sectionFileSettings => 'Pengaturan File';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
String get sectionLyrics => 'Lirik';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
String get lyricsMode => 'Mode Lirik';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
'Pilih cara lirik disimpan bersama unduhan Anda';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
String get lyricsModeEmbed => 'Sematkan dalam file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
String get lyricsModeEmbedSubtitle =>
|
||||
'Lirik tersimpan di dalam metadata FLAC';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
String get lyricsModeExternal => 'File .lrc eksternal';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
'File .lrc terpisah untuk pemutar musik seperti Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
String get lyricsModeBoth => 'Keduanya';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
String get lyricsModeBothSubtitle => 'Sematkan dan simpan file .lrc';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Warna';
|
||||
@@ -1119,10 +1123,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
String get trackLabel => 'Lebel';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
String get trackCopyright => 'Hak cipta';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Diunduh';
|
||||
@@ -1140,13 +1144,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
String get trackEmbedLyrics => 'Sematkan Lirik';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
String get trackLyricsEmbedded => 'Lirik berhasil disematkan';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
String get trackInstrumental => 'Lagu instrumental';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
||||
@@ -1201,7 +1205,48 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get storeClearFilters => 'Hapus filter';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load store';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
||||
@@ -1213,7 +1258,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get extensionId => 'ID';
|
||||
|
||||
@override
|
||||
String get extensionError => 'Error';
|
||||
String get extensionError => 'Terjadi kesalahan';
|
||||
|
||||
@override
|
||||
String get extensionCapabilities => 'Kemampuan';
|
||||
@@ -1352,19 +1397,51 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||
@@ -1379,18 +1456,19 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Gunakan Artis Album untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
'Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
'Nama lengkap artis digunakan untuk nama folder';
|
||||
|
||||
@override
|
||||
String get downloadSelectQuality => 'Pilih Kualitas';
|
||||
@@ -1412,24 +1490,24 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
'Apakah Anda yakin ingin menghapus semua unduhan?';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
String get settingsAutoExportFailed => 'Unduhan yang gagal diekspor otomatis';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
'Simpan unduhan yang gagal ke file TXT secara otomatis';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
String get settingsDownloadNetwork => 'Jaringan Unduhan';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Data Seluler';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
String get settingsDownloadNetworkWifiOnly => 'Hanya WiFi';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
'Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbum => 'Artis / Album';
|
||||
@@ -1457,11 +1535,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
String get albumFolderArtistAlbumSingles => 'Artis / Album + Singel';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
'Artis/Album/ dan Artis/Single/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||
@@ -1517,21 +1595,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get recentTypeSong => 'Lagu';
|
||||
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
String get recentTypePlaylist => 'Daftar putar';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
String get recentEmpty => 'Belum ada item terbaru';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
String get recentShowAllDownloads => 'Tampilkan Semua Unduhan';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
return 'Daftar Putar: $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
String get discographyDownload => 'Unduh Diskografi';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Unduh Semua';
|
||||
@@ -1658,6 +1736,25 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1822,44 +1919,44 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
'Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
'Penyematan metadata, sampul album, dan lirik secara otomatis';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
String get tutorialSearchTitle => 'Menemukan Musik';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
'Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
'Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
@@ -2108,6 +2205,28 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Antrekan FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Mencari kecocokan FLAC... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2117,7 +2236,8 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Konversi ke MP3, Opus, ALAC, atau FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2140,6 +2260,18 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Konversi lossless — tanpa kehilangan kualitas';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2393,6 +2525,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2415,4 +2558,418 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lyricsProvidersAtLeastOne =>
|
||||
'At least one provider must remain enabled';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToLoved(int count) {
|
||||
return 'Added $count tracks to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Buat folder sumber playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Unduhan dari playlist hanya memakai struktur folder normal.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'アルバム';
|
||||
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'インポート';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => '破棄';
|
||||
|
||||
@@ -758,7 +761,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get filenameFormat => 'ファイル名の形式';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
String get filenameShowAdvancedTags => '高度なタグを表示';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
@@ -1135,7 +1138,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
String get trackInstrumental => 'インストゥルメンタルのトラック';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'クリップボードにコピーしました';
|
||||
@@ -1189,6 +1192,47 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get storeClearFilters => 'フィルターを消去';
|
||||
|
||||
@override
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load store';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)';
|
||||
|
||||
@@ -1335,6 +1379,38 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
||||
|
||||
@@ -1638,6 +1714,25 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'アクション';
|
||||
|
||||
@@ -1814,7 +1909,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2088,6 +2183,28 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return '失敗: $error';
|
||||
@@ -2120,6 +2237,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'オーディオを変換中...';
|
||||
|
||||
@@ -2132,7 +2261,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackConvertFailed => '変換に失敗しました';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => '分割 CUE シート';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
@@ -2282,7 +2411,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
String get collectionRemoveFromFolder => 'フォルダから削除';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
@@ -2316,26 +2445,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
String get trackOptionAddToWishlist => 'ウィッシュリストに追加';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
String get trackOptionRemoveFromWishlist => 'ウィッシュから削除';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
String get collectionPlaylistChangeCover => 'カバー画像を変更';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
String get collectionPlaylistRemoveCover => 'カバー画像を削除';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: '個のトラック',
|
||||
one: '個のトラック',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
return '$count $_temp0を共有';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2356,7 +2485,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
String get selectionBatchConvertConfirmTitle => '一括変換';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
@@ -2373,6 +2502,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2395,4 +2535,418 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lyricsProvidersAtLeastOne =>
|
||||
'At least one provider must remain enabled';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToLoved(int count) {
|
||||
return 'Added $count tracks to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => '앨범';
|
||||
@@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => '불러오기';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => '취소';
|
||||
|
||||
@@ -1175,6 +1178,47 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get storeClearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load store';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
|
||||
@@ -1325,6 +1369,38 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1631,6 +1707,25 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1807,7 +1902,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2081,6 +2176,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2113,6 +2230,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2366,6 +2495,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2388,4 +2528,418 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lyricsProvidersAtLeastOne =>
|
||||
'At least one provider must remain enabled';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToLoved(int count) {
|
||||
return 'Added $count tracks to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
@@ -158,16 +158,16 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
||||
String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count parallel downloads';
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
'Parallel downloaden kan leiden tot rate-limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Store';
|
||||
@@ -271,7 +271,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutContributors => 'Contributors';
|
||||
|
||||
@override
|
||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
||||
String get aboutMobileDeveloper => '';
|
||||
|
||||
@override
|
||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||
@@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -1195,6 +1198,47 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get storeClearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load store';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
|
||||
@@ -1345,6 +1389,38 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1651,6 +1727,25 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Show when searching for existing tracks';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Actions';
|
||||
|
||||
@@ -1827,7 +1922,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2101,6 +2196,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Failed: $error';
|
||||
@@ -2133,6 +2250,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
|
||||
@@ -2386,6 +2515,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
@@ -2408,4 +2548,418 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get lyricsProvidersAtLeastOne =>
|
||||
'At least one provider must remain enabled';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersSaved => 'Lyrics provider priority saved';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDiscardContent =>
|
||||
'You have unsaved changes that will be lost.';
|
||||
|
||||
@override
|
||||
String get lyricsProviderSpotifyApiDesc =>
|
||||
'Spotify-sourced synced lyrics via community API';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLrclibDesc => 'Open-source synced lyrics database';
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String snackbarAddedTracksToLoved(int count) {
|
||||
return 'Added $count tracks to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
return 'Download $count tracks?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
+2983
-2134
File diff suppressed because it is too large
Load Diff
@@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
|
||||
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Альбомы';
|
||||
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get dialogImport => 'Импорт';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Отменить';
|
||||
|
||||
@@ -703,15 +706,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get errorNoTracksFound => 'Треки не найдены';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
String get errorUrlNotRecognized => 'Ссылка не распознана';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
'Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
'Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
@@ -787,11 +790,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get folderOrganizationNone => 'Без организации';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'По плейлисту';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Отдельная папка для каждого плейлиста';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'По исполнителю';
|
||||
@@ -1216,6 +1219,47 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get storeClearFilters => 'Очистить фильтры';
|
||||
|
||||
@override
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load store';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)';
|
||||
|
||||
@@ -1370,6 +1414,38 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Фактическое качество зависит от доступности треков в сервисе';
|
||||
@@ -1406,7 +1482,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
'Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
@@ -1687,6 +1763,25 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
'Показать при поиске существующих треков';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Действия';
|
||||
|
||||
@@ -1877,7 +1972,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
|
||||
'Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -1973,7 +2068,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
return 'Удалено $count утерянных записей из истории';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1998,7 +2093,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get cacheSectionStorage => 'Кэшированные данные';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
String get cacheSectionMaintenance => 'Обслуживание';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'Папка кэша приложения';
|
||||
@@ -2044,7 +2139,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
'Удалить записи из истории загрузок и библиотеки, которые остались без файлов.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'Нет кэшированных данных';
|
||||
@@ -2092,7 +2187,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
'Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
@@ -2154,6 +2249,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackReEnrichFfmpegFailed =>
|
||||
'Ошибка встраивания метаданных FFmpeg';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackSaveFailed(String error) {
|
||||
return 'Ошибка: $error';
|
||||
@@ -2186,6 +2303,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackConvertConfirmMessageLossless(
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Конвертация аудио...';
|
||||
|
||||
@@ -2198,52 +2327,52 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackConvertFailed => 'Ошибка конвертации';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => 'Разделить CUE Sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
String get cueSplitSubtitle => 'Разделить файл CUE+FLAC на отдельные треки';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
return 'Album: $album';
|
||||
return 'Альбом: $album';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
return 'Артист: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
return '$count треков';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
String get cueSplitConfirmTitle => 'Разделенный CUE-альбом';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
return 'Разбить \"$album\" на $count отдельных FLAC-файлов?';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
return 'Разделение CUE sheet... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
return 'Успешно разделено на $count треков';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
String get cueSplitFailed => 'Разделение CUE не удалось';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
String get cueSplitNoAudioFile => 'Аудиофайл для этого CUE sheet не найден';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
String get cueSplitButton => 'Разделить на Треки';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Создать';
|
||||
@@ -2409,7 +2538,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
String get selectionShareNoFiles =>
|
||||
'Файлы, доступные для совместного доступа, не найдены';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
@@ -2442,7 +2572,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
return 'Преобразовать $count $_temp0 в $format с $bitrate?';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2467,4 +2608,418 @@ 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 homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
|
||||
@override
|
||||
String get snackbarLoadingCueSheet => 'Loading CUE sheet...';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataSaved => 'Metadata saved successfully';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToEmbedLyrics => 'Failed to embed lyrics';
|
||||
|
||||
@override
|
||||
String get snackbarFailedToWriteStorage => 'Failed to write back to storage';
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get snackbarNoActionDefined => 'No action defined for this button';
|
||||
|
||||
@override
|
||||
String get noTracksFoundForAlbum => 'No tracks found for this album';
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
|
||||
@override
|
||||
String get downloadFilenameDescription =>
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
|
||||
@override
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
'Netease: Include Translation';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
'Netease: Include Romanization';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
String bulkDownloadPlaylistsButton(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Download $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
|
||||
|
||||
@override
|
||||
String get snackbarSelectedPlaylistsEmpty =>
|
||||
'Selected playlists have no tracks';
|
||||
|
||||
@override
|
||||
String playlistsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count playlists',
|
||||
one: '1 playlist',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFill => 'Auto-fill from online';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillDesc =>
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoResults =>
|
||||
'No matching metadata found online';
|
||||
|
||||
@override
|
||||
String editMetadataAutoFillDone(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'fields',
|
||||
one: 'field',
|
||||
);
|
||||
return 'Filled $count $_temp0 from online metadata';
|
||||
}
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillNoneSelected =>
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldIsrc => 'ISRC';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
}
|
||||
|
||||
+690
-142
File diff suppressed because it is too large
Load Diff
+5354
-4680
File diff suppressed because it is too large
Load Diff
+143
-39
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.",
|
||||
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -555,7 +555,7 @@
|
||||
"@setupDownloadLocationTitle": {
|
||||
"description": "Download location dialog title"
|
||||
},
|
||||
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
|
||||
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
|
||||
"@setupDownloadLocationIosMessage": {
|
||||
"description": "iOS-specific folder info"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link wurde nicht erkannt",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Laden fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Kann {item} nicht lade wegen fehlender Erweiterungsquelle",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -947,7 +959,7 @@
|
||||
"@selectionAllSelected": {
|
||||
"description": "Status - all items selected"
|
||||
},
|
||||
"selectionSelectToDelete": "Titel zum Löschen auswählen",
|
||||
"selectionSelectToDelete": "Titel zum Löschen wählen",
|
||||
"@selectionSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
@@ -975,7 +987,7 @@
|
||||
"@searchArtists": {
|
||||
"description": "Search result category - artists"
|
||||
},
|
||||
"searchAlbums": "Albums",
|
||||
"searchAlbums": "Alben",
|
||||
"@searchAlbums": {
|
||||
"description": "Search result category - albums"
|
||||
},
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "Nach Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Ordner für jede Playlist trennen",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "Nach Künstler",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1019,7 +1039,7 @@
|
||||
"@folderOrganizationDescription": {
|
||||
"description": "Folder organization sheet description"
|
||||
},
|
||||
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Verzeichnis",
|
||||
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Ordner",
|
||||
"@folderOrganizationNoneSubtitle": {
|
||||
"description": "Subtitle for no organization option"
|
||||
},
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Integriert",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Erweiterung",
|
||||
"@providerExtension": {
|
||||
@@ -1769,7 +1789,7 @@
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
},
|
||||
"downloadDirectory": "Downloadverzeichnis",
|
||||
"downloadDirectory": "Download-Ordner",
|
||||
"@downloadDirectory": {
|
||||
"description": "Setting - download folder"
|
||||
},
|
||||
@@ -1777,15 +1797,15 @@
|
||||
"@downloadSeparateSinglesFolder": {
|
||||
"description": "Setting - separate folder for singles"
|
||||
},
|
||||
"downloadAlbumFolderStructure": "Album Folder Structure",
|
||||
"downloadAlbumFolderStructure": "Album-Ordnerstruktur",
|
||||
"@downloadAlbumFolderStructure": {
|
||||
"description": "Setting - album folder organization"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||
"downloadUsePrimaryArtistOnly": "Primärer Künstler nur für Ordner",
|
||||
"@downloadUsePrimaryArtistOnly": {
|
||||
"description": "Setting - strip featured artists from folder name"
|
||||
},
|
||||
@@ -1793,7 +1813,7 @@
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||
"description": "Subtitle when primary artist only is enabled"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Vollständiger Künstler für Ordnername",
|
||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||
"description": "Subtitle when primary artist only is disabled"
|
||||
},
|
||||
@@ -1821,7 +1841,7 @@
|
||||
"@queueClearAllMessage": {
|
||||
"description": "Clear queue confirmation"
|
||||
},
|
||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||
"settingsAutoExportFailed": "Auto-Export fehlgeschlagener Downloads",
|
||||
"@settingsAutoExportFailed": {
|
||||
"description": "Setting toggle for auto-export"
|
||||
},
|
||||
@@ -1849,15 +1869,15 @@
|
||||
"@albumFolderArtistAlbum": {
|
||||
"description": "Album folder option"
|
||||
},
|
||||
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
|
||||
"albumFolderArtistAlbumSubtitle": "Alben/Künster Name/Album Name/",
|
||||
"@albumFolderArtistAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
|
||||
"albumFolderArtistYearAlbum": "Künstler / [Year] Album",
|
||||
"@albumFolderArtistYearAlbum": {
|
||||
"description": "Album folder option with year"
|
||||
},
|
||||
"albumFolderArtistYearAlbumSubtitle": "Albums/Künster Name/[2005] Album Name/",
|
||||
"albumFolderArtistYearAlbumSubtitle": "Alben/Künster Name/[2005] Album Name/",
|
||||
"@albumFolderArtistYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
@@ -1873,15 +1893,15 @@
|
||||
"@albumFolderYearAlbum": {
|
||||
"description": "Album folder option with year"
|
||||
},
|
||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
||||
"albumFolderYearAlbumSubtitle": "Alben/[2005] Album Name/",
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"albumFolderArtistAlbumSingles": "Künstler / Album + Singles",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Künstler/Album/ und Künstler/Singles/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
@@ -1924,7 +1944,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
||||
"downloadedAlbumSelectToDelete": "Titel zum Löschen wählen",
|
||||
"@downloadedAlbumSelectToDelete": {
|
||||
"description": "Placeholder when nothing selected"
|
||||
},
|
||||
@@ -1996,7 +2016,7 @@
|
||||
"@discographyAlbumsOnly": {
|
||||
"description": "Option - download only albums"
|
||||
},
|
||||
"discographyAlbumsOnlySubtitle": "{count} Titel von {albumCount} Albums",
|
||||
"discographyAlbumsOnlySubtitle": "{count} Titel aus {albumCount} Alben",
|
||||
"@discographyAlbumsOnlySubtitle": {
|
||||
"description": "Subtitle showing album tracks count",
|
||||
"placeholders": {
|
||||
@@ -2028,7 +2048,7 @@
|
||||
"@discographySelectAlbums": {
|
||||
"description": "Option - manually select albums to download"
|
||||
},
|
||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
||||
"discographySelectAlbumsSubtitle": "Wähle bestimmte Alben oder Singles",
|
||||
"@discographySelectAlbumsSubtitle": {
|
||||
"description": "Subtitle for select albums option"
|
||||
},
|
||||
@@ -2036,7 +2056,7 @@
|
||||
"@discographyFetchingTracks": {
|
||||
"description": "Progress - fetching album tracks"
|
||||
},
|
||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
||||
"discographyFetchingAlbum": "Lade {current} von {total}...",
|
||||
"@discographyFetchingAlbum": {
|
||||
"description": "Progress - fetching specific album",
|
||||
"placeholders": {
|
||||
@@ -2061,7 +2081,7 @@
|
||||
"@discographyDownloadSelected": {
|
||||
"description": "Button - download selected albums"
|
||||
},
|
||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
||||
"discographyAddedToQueue": "{count} Titel zur Warteschlange hinzugefügt",
|
||||
"@discographyAddedToQueue": {
|
||||
"description": "Snackbar - tracks added from discography",
|
||||
"placeholders": {
|
||||
@@ -2086,7 +2106,7 @@
|
||||
"@discographyNoAlbums": {
|
||||
"description": "Error - no albums found for artist"
|
||||
},
|
||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
||||
"discographyFailedToFetch": "Fehler beim Abrufen einiger Alben",
|
||||
"@discographyFailedToFetch": {
|
||||
"description": "Error - some albums failed to load"
|
||||
},
|
||||
@@ -2098,15 +2118,15 @@
|
||||
"@allFilesAccess": {
|
||||
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
||||
},
|
||||
"allFilesAccessEnabledSubtitle": "Can write to any folder",
|
||||
"allFilesAccessEnabledSubtitle": "Darf in jeden Ordner schreiben",
|
||||
"@allFilesAccessEnabledSubtitle": {
|
||||
"description": "Subtitle when all files access is enabled"
|
||||
},
|
||||
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
|
||||
"allFilesAccessDisabledSubtitle": "Nur auf Medienordner begrenzt",
|
||||
"@allFilesAccessDisabledSubtitle": {
|
||||
"description": "Subtitle when all files access is disabled"
|
||||
},
|
||||
"allFilesAccessDescription": "Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.",
|
||||
"allFilesAccessDescription": "Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).",
|
||||
"@allFilesAccessDescription": {
|
||||
"description": "Description explaining when to enable all files access"
|
||||
},
|
||||
@@ -2122,7 +2142,7 @@
|
||||
"@settingsLocalLibrary": {
|
||||
"description": "Settings menu item - local library"
|
||||
},
|
||||
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
||||
"settingsLocalLibrarySubtitle": "Musik scannen & Duplikate erkennen",
|
||||
"@settingsLocalLibrarySubtitle": {
|
||||
"description": "Subtitle for local library settings"
|
||||
},
|
||||
@@ -2130,7 +2150,7 @@
|
||||
"@settingsCache": {
|
||||
"description": "Settings menu item - cache management"
|
||||
},
|
||||
"settingsCacheSubtitle": "View size and clear cached data",
|
||||
"settingsCacheSubtitle": "Größe anzeigen und Daten im Cache leeren",
|
||||
"@settingsCacheSubtitle": {
|
||||
"description": "Subtitle for cache management menu"
|
||||
},
|
||||
@@ -2146,7 +2166,7 @@
|
||||
"@libraryEnableLocalLibrary": {
|
||||
"description": "Toggle to enable library scanning"
|
||||
},
|
||||
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
||||
"libraryEnableLocalLibrarySubtitle": "Scan und verfolge deine bestehende Musik",
|
||||
"@libraryEnableLocalLibrarySubtitle": {
|
||||
"description": "Subtitle for enable toggle"
|
||||
},
|
||||
@@ -2158,7 +2178,7 @@
|
||||
"@libraryFolderHint": {
|
||||
"description": "Placeholder when no folder selected"
|
||||
},
|
||||
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
||||
"libraryShowDuplicateIndicator": "Duplikat Indikator anzeigen",
|
||||
"@libraryShowDuplicateIndicator": {
|
||||
"description": "Toggle for duplicate indicator in search"
|
||||
},
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik",
|
||||
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2455,7 +2475,7 @@
|
||||
"@tutorialSettingsDesc": {
|
||||
"description": "Tutorial settings page description"
|
||||
},
|
||||
"tutorialSettingsTip1": "Downloadverzeichnis und Ordnerorganisation ändern",
|
||||
"tutorialSettingsTip1": "Download-Ordner und Ordner-Organisation ändern",
|
||||
"@tutorialSettingsTip1": {
|
||||
"description": "Tutorial settings tip 1"
|
||||
},
|
||||
@@ -2529,7 +2549,7 @@
|
||||
"@cacheSectionMaintenance": {
|
||||
"description": "Section header for cleanup actions"
|
||||
},
|
||||
"cacheAppDirectory": "App-Cache Verzeichnis",
|
||||
"cacheAppDirectory": "App-Cache Ordner",
|
||||
"@cacheAppDirectory": {
|
||||
"description": "Cache item title for app cache directory"
|
||||
},
|
||||
@@ -2537,7 +2557,7 @@
|
||||
"@cacheAppDirectoryDesc": {
|
||||
"description": "Description of what app cache directory contains"
|
||||
},
|
||||
"cacheTempDirectory": "Temporäres Verzeichnis",
|
||||
"cacheTempDirectory": "Temporärer Ordner",
|
||||
"@cacheTempDirectory": {
|
||||
"description": "Cache item title for temporary files directory"
|
||||
},
|
||||
@@ -2705,7 +2725,7 @@
|
||||
"@trackEditMetadata": {
|
||||
"description": "Menu action - edit embedded metadata"
|
||||
},
|
||||
"trackCoverSaved": "Cover art saved to {fileName}",
|
||||
"trackCoverSaved": "Cover in {fileName} gespeichert",
|
||||
"@trackCoverSaved": {
|
||||
"description": "Snackbar after cover art saved",
|
||||
"placeholders": {
|
||||
@@ -2714,7 +2734,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackCoverNoSource": "No cover art source available",
|
||||
"trackCoverNoSource": "Keine Cover Quelle vorhanden",
|
||||
"@trackCoverNoSource": {
|
||||
"description": "Snackbar when no cover art URL or embedded cover"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "CUE-Sheet aufteilen",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "CUE+FLAC in einzelne Titel aufteilen",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Künstler: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} Titel",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "CUE-Album aufteilen",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Soll „{album}“ in {count} einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "CUE-Sheet wird geteilt... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "{count} Titel erfolgreich aufgeteilt",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE-Aufteilung fehlgeschlagen",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audiodatei für dieses CUE-Sheet nicht gefunden",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "In Titel aufteilen",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Erstellen",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
@@ -3094,11 +3198,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Künstlerordner verwenden den Album-Interpreten, wenn verfügbar",
|
||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Interpret-Ordner verwenden Album-Interpret, sofern vorhanden",
|
||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
||||
"description": "Subtitle when Album Artist is used for folder naming"
|
||||
},
|
||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Künstler-Ordner nur für Titel-Künstler",
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
|
||||
+700
-2
@@ -671,6 +671,10 @@
|
||||
"@dialogImport": {
|
||||
"description": "Dialog button - import data"
|
||||
},
|
||||
"dialogDownload": "Download",
|
||||
"@dialogDownload": {
|
||||
"description": "Dialog button - download action"
|
||||
},
|
||||
"dialogDiscard": "Discard",
|
||||
"@dialogDiscard": {
|
||||
"description": "Dialog button - discard changes"
|
||||
@@ -1570,7 +1574,59 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"storeAddRepoTitle": "Add Extension Repository",
|
||||
"@storeAddRepoTitle": {
|
||||
"description": "Store setup screen - heading when no repo is configured"
|
||||
},
|
||||
"storeAddRepoDescription": "Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.",
|
||||
"@storeAddRepoDescription": {
|
||||
"description": "Store setup screen - explanatory text"
|
||||
},
|
||||
"storeRepoUrlLabel": "Repository URL",
|
||||
"@storeRepoUrlLabel": {
|
||||
"description": "Label for the repository URL input field"
|
||||
},
|
||||
"storeRepoUrlHint": "https://github.com/user/repo",
|
||||
"@storeRepoUrlHint": {
|
||||
"description": "Hint/placeholder for the repository URL input field"
|
||||
},
|
||||
"storeRepoUrlHelper": "e.g. https://github.com/user/extensions-repo",
|
||||
"@storeRepoUrlHelper": {
|
||||
"description": "Helper text below the repository URL input field"
|
||||
},
|
||||
"storeAddRepoButton": "Add Repository",
|
||||
"@storeAddRepoButton": {
|
||||
"description": "Button to submit a new repository URL"
|
||||
},
|
||||
"storeChangeRepoTooltip": "Change repository",
|
||||
"@storeChangeRepoTooltip": {
|
||||
"description": "Tooltip for the change-repository icon button in the app bar"
|
||||
},
|
||||
"storeRepoDialogTitle": "Extension Repository",
|
||||
"@storeRepoDialogTitle": {
|
||||
"description": "Title of the change/remove repository dialog"
|
||||
},
|
||||
"storeRepoDialogCurrent": "Current repository:",
|
||||
"@storeRepoDialogCurrent": {
|
||||
"description": "Label shown above the current repository URL in the dialog"
|
||||
},
|
||||
"storeNewRepoUrlLabel": "New Repository URL",
|
||||
"@storeNewRepoUrlLabel": {
|
||||
"description": "Label for the new repository URL field inside the dialog"
|
||||
},
|
||||
"storeLoadError": "Failed to load store",
|
||||
"@storeLoadError": {
|
||||
"description": "Error heading when the store cannot be loaded"
|
||||
},
|
||||
"storeEmptyNoExtensions": "No extensions available",
|
||||
"@storeEmptyNoExtensions": {
|
||||
"description": "Message when store has no extensions"
|
||||
},
|
||||
"storeEmptyNoResults": "No extensions found",
|
||||
"@storeEmptyNoResults": {
|
||||
"description": "Message when search/filter returns no results"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer)",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
@@ -1769,6 +1825,46 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
},
|
||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
@@ -2186,6 +2282,30 @@
|
||||
"@libraryShowDuplicateIndicatorSubtitle": {
|
||||
"description": "Subtitle for duplicate indicator toggle"
|
||||
},
|
||||
"libraryAutoScan": "Auto Scan",
|
||||
"@libraryAutoScan": {
|
||||
"description": "Setting for automatic library scanning"
|
||||
},
|
||||
"libraryAutoScanSubtitle": "Automatically scan your library for new files",
|
||||
"@libraryAutoScanSubtitle": {
|
||||
"description": "Subtitle for auto scan setting"
|
||||
},
|
||||
"libraryAutoScanOff": "Off",
|
||||
"@libraryAutoScanOff": {
|
||||
"description": "Auto scan disabled"
|
||||
},
|
||||
"libraryAutoScanOnOpen": "Every app open",
|
||||
"@libraryAutoScanOnOpen": {
|
||||
"description": "Auto scan when app opens"
|
||||
},
|
||||
"libraryAutoScanDaily": "Daily",
|
||||
"@libraryAutoScanDaily": {
|
||||
"description": "Auto scan once per day"
|
||||
},
|
||||
"libraryAutoScanWeekly": "Weekly",
|
||||
"@libraryAutoScanWeekly": {
|
||||
"description": "Auto scan once per week"
|
||||
},
|
||||
"libraryActions": "Actions",
|
||||
"@libraryActions": {
|
||||
"description": "Section header for library actions"
|
||||
@@ -2763,6 +2883,47 @@
|
||||
"@trackReEnrichFfmpegFailed": {
|
||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||
},
|
||||
"queueFlacAction": "Queue FLAC",
|
||||
"@queueFlacAction": {
|
||||
"description": "Action/button label for queueing FLAC redownloads for local tracks"
|
||||
},
|
||||
"queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected",
|
||||
"@queueFlacConfirmMessage": {
|
||||
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})",
|
||||
"@queueFlacFindingProgress": {
|
||||
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacNoReliableMatches": "No reliable online matches found for the selection",
|
||||
"@queueFlacNoReliableMatches": {
|
||||
"description": "Snackbar when no safe FLAC redownload matches were found"
|
||||
},
|
||||
"queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}",
|
||||
"@queueFlacQueuedWithSkipped": {
|
||||
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
|
||||
"placeholders": {
|
||||
"addedCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"skippedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
@@ -2776,7 +2937,7 @@
|
||||
"@trackConvertFormat": {
|
||||
"description": "Menu item - convert audio format"
|
||||
},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
|
||||
"@trackConvertFormatSubtitle": {
|
||||
"description": "Subtitle for convert format menu item"
|
||||
},
|
||||
@@ -2811,6 +2972,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
|
||||
"@trackConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless-to-lossless conversion",
|
||||
"placeholders": {
|
||||
"sourceFormat": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetFormat": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertLosslessHint": "Lossless conversion — no quality loss",
|
||||
"@trackConvertLosslessHint": {
|
||||
"description": "Hint shown when converting between lossless formats"
|
||||
},
|
||||
"trackConvertConverting": "Converting audio...",
|
||||
"@trackConvertConverting": {
|
||||
"description": "Snackbar while converting"
|
||||
@@ -3162,6 +3339,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
|
||||
"@selectionBatchConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless batch conversion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
@@ -3205,5 +3394,514 @@
|
||||
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
},
|
||||
|
||||
"lyricsProvidersTitle": "Lyrics Providers",
|
||||
"@lyricsProvidersTitle": {
|
||||
"description": "Title for the lyrics provider priority page"
|
||||
},
|
||||
"lyricsProvidersDescription": "Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.",
|
||||
"@lyricsProvidersDescription": {
|
||||
"description": "Description on the lyrics provider priority page"
|
||||
},
|
||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
||||
"@lyricsProvidersInfoText": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
"lyricsProvidersEnabledSection": "Enabled ({count})",
|
||||
"@lyricsProvidersEnabledSection": {
|
||||
"description": "Section header for enabled providers",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lyricsProvidersDisabledSection": "Disabled ({count})",
|
||||
"@lyricsProvidersDisabledSection": {
|
||||
"description": "Section header for disabled providers",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lyricsProvidersAtLeastOne": "At least one provider must remain enabled",
|
||||
"@lyricsProvidersAtLeastOne": {
|
||||
"description": "Snackbar when user tries to disable the last enabled provider"
|
||||
},
|
||||
"lyricsProvidersSaved": "Lyrics provider priority saved",
|
||||
"@lyricsProvidersSaved": {
|
||||
"description": "Snackbar after saving lyrics provider priority"
|
||||
},
|
||||
"lyricsProvidersDiscardContent": "You have unsaved changes that will be lost.",
|
||||
"@lyricsProvidersDiscardContent": {
|
||||
"description": "Body text of the discard-changes dialog on lyrics provider page"
|
||||
},
|
||||
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
|
||||
"@lyricsProviderSpotifyApiDesc": {
|
||||
"description": "Description for Spotify Lyrics API provider"
|
||||
},
|
||||
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
|
||||
"@lyricsProviderLrclibDesc": {
|
||||
"description": "Description for LRCLIB provider"
|
||||
},
|
||||
"lyricsProviderNeteaseDesc": "NetEase Cloud Music (good for Asian songs)",
|
||||
"@lyricsProviderNeteaseDesc": {
|
||||
"description": "Description for Netease provider"
|
||||
},
|
||||
"lyricsProviderMusixmatchDesc": "Largest lyrics database (multi-language)",
|
||||
"@lyricsProviderMusixmatchDesc": {
|
||||
"description": "Description for Musixmatch provider"
|
||||
},
|
||||
"lyricsProviderAppleMusicDesc": "Word-by-word synced lyrics (via proxy)",
|
||||
"@lyricsProviderAppleMusicDesc": {
|
||||
"description": "Description for Apple Music provider"
|
||||
},
|
||||
"lyricsProviderQqMusicDesc": "QQ Music (good for Chinese songs, via proxy)",
|
||||
"@lyricsProviderQqMusicDesc": {
|
||||
"description": "Description for QQ Music provider"
|
||||
},
|
||||
"lyricsProviderExtensionDesc": "Extension provider",
|
||||
"@lyricsProviderExtensionDesc": {
|
||||
"description": "Generic description for extension-based lyrics providers"
|
||||
},
|
||||
|
||||
"safMigrationTitle": "Storage Update Required",
|
||||
"@safMigrationTitle": {
|
||||
"description": "Title of SAF migration dialog"
|
||||
},
|
||||
"safMigrationMessage1": "SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.",
|
||||
"@safMigrationMessage1": {
|
||||
"description": "First paragraph of SAF migration dialog"
|
||||
},
|
||||
"safMigrationMessage2": "Please select your download folder again to switch to the new storage system.",
|
||||
"@safMigrationMessage2": {
|
||||
"description": "Second paragraph of SAF migration dialog"
|
||||
},
|
||||
"safMigrationSuccess": "Download folder updated to SAF mode",
|
||||
"@safMigrationSuccess": {
|
||||
"description": "Snackbar after successfully migrating to SAF"
|
||||
},
|
||||
|
||||
"settingsDonate": "Donate",
|
||||
"@settingsDonate": {
|
||||
"description": "Settings menu item - donate"
|
||||
},
|
||||
"settingsDonateSubtitle": "Support SpotiFLAC-Mobile development",
|
||||
"@settingsDonateSubtitle": {
|
||||
"description": "Subtitle for donate menu item"
|
||||
},
|
||||
|
||||
"tooltipLoveAll": "Love All",
|
||||
"@tooltipLoveAll": {
|
||||
"description": "Tooltip for the Love All button on album/playlist screens"
|
||||
},
|
||||
"tooltipAddToPlaylist": "Add to Playlist",
|
||||
"@tooltipAddToPlaylist": {
|
||||
"description": "Tooltip for the Add to Playlist button"
|
||||
},
|
||||
"snackbarRemovedTracksFromLoved": "Removed {count} tracks from Loved",
|
||||
"@snackbarRemovedTracksFromLoved": {
|
||||
"description": "Snackbar after removing multiple tracks from Loved folder",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarAddedTracksToLoved": "Added {count} tracks to Loved",
|
||||
"@snackbarAddedTracksToLoved": {
|
||||
"description": "Snackbar after adding multiple tracks to Loved folder",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"dialogDownloadAllTitle": "Download All",
|
||||
"@dialogDownloadAllTitle": {
|
||||
"description": "Title of the Download All confirmation dialog"
|
||||
},
|
||||
"dialogDownloadAllMessage": "Download {count} tracks?",
|
||||
"@dialogDownloadAllMessage": {
|
||||
"description": "Body of the Download All confirmation dialog",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialogDownload": "Download",
|
||||
"@dialogDownload": {
|
||||
"description": "Confirm button in Download All dialog"
|
||||
},
|
||||
|
||||
"homeSkipAlreadyDownloaded": "Skip already downloaded songs",
|
||||
"@homeSkipAlreadyDownloaded": {
|
||||
"description": "Checkbox label in import dialog to skip already-downloaded songs"
|
||||
},
|
||||
"homeGoToAlbum": "Go to Album",
|
||||
"@homeGoToAlbum": {
|
||||
"description": "Context menu item to navigate to the album page"
|
||||
},
|
||||
"homeAlbumInfoUnavailable": "Album info not available",
|
||||
"@homeAlbumInfoUnavailable": {
|
||||
"description": "Snackbar when album info cannot be loaded"
|
||||
},
|
||||
|
||||
"snackbarLoadingCueSheet": "Loading CUE sheet...",
|
||||
"@snackbarLoadingCueSheet": {
|
||||
"description": "Snackbar while loading a CUE sheet file"
|
||||
},
|
||||
"snackbarMetadataSaved": "Metadata saved successfully",
|
||||
"@snackbarMetadataSaved": {
|
||||
"description": "Snackbar after successfully saving track metadata"
|
||||
},
|
||||
"snackbarFailedToEmbedLyrics": "Failed to embed lyrics",
|
||||
"@snackbarFailedToEmbedLyrics": {
|
||||
"description": "Snackbar when lyrics embedding fails"
|
||||
},
|
||||
"snackbarFailedToWriteStorage": "Failed to write back to storage",
|
||||
"@snackbarFailedToWriteStorage": {
|
||||
"description": "Snackbar when writing metadata back to file fails"
|
||||
},
|
||||
"snackbarError": "Error: {error}",
|
||||
"@snackbarError": {
|
||||
"description": "Generic error snackbar with error detail",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"snackbarNoActionDefined": "No action defined for this button",
|
||||
"@snackbarNoActionDefined": {
|
||||
"description": "Snackbar when an extension button has no action configured"
|
||||
},
|
||||
|
||||
"noTracksFoundForAlbum": "No tracks found for this album",
|
||||
"@noTracksFoundForAlbum": {
|
||||
"description": "Empty state message when an album has no tracks"
|
||||
},
|
||||
|
||||
"downloadLocationSubtitle": "Choose storage mode for downloaded files.",
|
||||
"@downloadLocationSubtitle": {
|
||||
"description": "Subtitle text in Android download location bottom sheet"
|
||||
},
|
||||
"storageModeAppFolder": "App folder (non-SAF)",
|
||||
"@storageModeAppFolder": {
|
||||
"description": "Storage mode option - use legacy app folder"
|
||||
},
|
||||
"storageModeAppFolderSubtitle": "Use default Music/SpotiFLAC path",
|
||||
"@storageModeAppFolderSubtitle": {
|
||||
"description": "Subtitle for app folder storage mode"
|
||||
},
|
||||
"storageModeSaf": "SAF folder",
|
||||
"@storageModeSaf": {
|
||||
"description": "Storage mode option - use Android SAF picker"
|
||||
},
|
||||
"storageModeSafSubtitle": "Pick folder via Android Storage Access Framework",
|
||||
"@storageModeSafSubtitle": {
|
||||
"description": "Subtitle for SAF storage mode"
|
||||
},
|
||||
"downloadFilenameDescription": "Customize how your files are named.",
|
||||
"@downloadFilenameDescription": {
|
||||
"description": "Description text in filename format bottom sheet"
|
||||
},
|
||||
"downloadFilenameInsertTag": "Tap to insert tag:",
|
||||
"@downloadFilenameInsertTag": {
|
||||
"description": "Label above filename tag chips"
|
||||
},
|
||||
"downloadSeparateSinglesEnabled": "Albums/ and Singles/ folders",
|
||||
"@downloadSeparateSinglesEnabled": {
|
||||
"description": "Subtitle when separate singles folder is enabled"
|
||||
},
|
||||
"downloadSeparateSinglesDisabled": "All files in same structure",
|
||||
"@downloadSeparateSinglesDisabled": {
|
||||
"description": "Subtitle when separate singles folder is disabled"
|
||||
},
|
||||
"downloadArtistNameFilters": "Artist Name Filters",
|
||||
"@downloadArtistNameFilters": {
|
||||
"description": "Setting title for artist folder filter options"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolder": "Create playlist source folder",
|
||||
"@downloadCreatePlaylistSourceFolder": {
|
||||
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.",
|
||||
"@downloadCreatePlaylistSourceFolderEnabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is enabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.",
|
||||
"@downloadCreatePlaylistSourceFolderDisabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is disabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.",
|
||||
"@downloadCreatePlaylistSourceFolderRedundant": {
|
||||
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
|
||||
},
|
||||
"downloadSongLinkRegion": "SongLink Region",
|
||||
"@downloadSongLinkRegion": {
|
||||
"description": "Setting title for SongLink country region"
|
||||
},
|
||||
"downloadNetworkCompatibilityMode": "Network compatibility mode",
|
||||
"@downloadNetworkCompatibilityMode": {
|
||||
"description": "Setting title for network compatibility toggle"
|
||||
},
|
||||
"downloadNetworkCompatibilityModeEnabled": "Enabled: try HTTP + accept invalid TLS certificates (unsafe)",
|
||||
"@downloadNetworkCompatibilityModeEnabled": {
|
||||
"description": "Subtitle when network compatibility mode is enabled"
|
||||
},
|
||||
"downloadNetworkCompatibilityModeDisabled": "Off: strict HTTPS certificate validation (recommended)",
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"description": "Subtitle when network compatibility mode is disabled"
|
||||
},
|
||||
"downloadSelectServiceToEnable": "Select a built-in service to enable",
|
||||
"@downloadSelectServiceToEnable": {
|
||||
"description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
|
||||
},
|
||||
|
||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality",
|
||||
"@downloadSelectTidalQobuz": {
|
||||
"description": "Info hint when non-Tidal/Qobuz service is selected"
|
||||
},
|
||||
"downloadEmbedLyricsDisabled": "Disabled while Embed Metadata is turned off",
|
||||
"@downloadEmbedLyricsDisabled": {
|
||||
"description": "Subtitle for Embed Lyrics when Embed Metadata is disabled"
|
||||
},
|
||||
"downloadNeteaseIncludeTranslation": "Netease: Include Translation",
|
||||
"@downloadNeteaseIncludeTranslation": {
|
||||
"description": "Toggle title for including Netease translated lyrics"
|
||||
},
|
||||
"downloadNeteaseIncludeTranslationEnabled": "Append translated lyrics when available",
|
||||
"@downloadNeteaseIncludeTranslationEnabled": {
|
||||
"description": "Subtitle when Netease translation is enabled"
|
||||
},
|
||||
"downloadNeteaseIncludeTranslationDisabled": "Use original lyrics only",
|
||||
"@downloadNeteaseIncludeTranslationDisabled": {
|
||||
"description": "Subtitle when Netease translation is disabled"
|
||||
},
|
||||
"downloadNeteaseIncludeRomanization": "Netease: Include Romanization",
|
||||
"@downloadNeteaseIncludeRomanization": {
|
||||
"description": "Toggle title for including Netease romanized lyrics"
|
||||
},
|
||||
"downloadNeteaseIncludeRomanizationEnabled": "Append romanized lyrics when available",
|
||||
"@downloadNeteaseIncludeRomanizationEnabled": {
|
||||
"description": "Subtitle when Netease romanization is enabled"
|
||||
},
|
||||
"downloadNeteaseIncludeRomanizationDisabled": "Disabled",
|
||||
"@downloadNeteaseIncludeRomanizationDisabled": {
|
||||
"description": "Subtitle when Netease romanization is disabled"
|
||||
},
|
||||
"downloadAppleQqMultiPerson": "Apple/QQ Multi-Person Word-by-Word",
|
||||
"@downloadAppleQqMultiPerson": {
|
||||
"description": "Toggle title for Apple/QQ multi-person word-by-word lyrics"
|
||||
},
|
||||
"downloadAppleQqMultiPersonEnabled": "Enable v1/v2 speaker and [bg:] tags",
|
||||
"@downloadAppleQqMultiPersonEnabled": {
|
||||
"description": "Subtitle when multi-person word-by-word is enabled"
|
||||
},
|
||||
"downloadAppleQqMultiPersonDisabled": "Simplified word-by-word formatting",
|
||||
"@downloadAppleQqMultiPersonDisabled": {
|
||||
"description": "Subtitle when multi-person word-by-word is disabled"
|
||||
},
|
||||
"downloadMusixmatchLanguage": "Musixmatch Language",
|
||||
"@downloadMusixmatchLanguage": {
|
||||
"description": "Setting title for Musixmatch language preference"
|
||||
},
|
||||
"downloadMusixmatchLanguageAuto": "Auto (original)",
|
||||
"@downloadMusixmatchLanguageAuto": {
|
||||
"description": "Option label when Musixmatch uses original language"
|
||||
},
|
||||
"downloadFilterContributing": "Filter contributing artists in Album Artist",
|
||||
"@downloadFilterContributing": {
|
||||
"description": "Toggle title for filtering contributing artists in Album Artist metadata"
|
||||
},
|
||||
"downloadFilterContributingEnabled": "Album Artist metadata uses primary artist only",
|
||||
"@downloadFilterContributingEnabled": {
|
||||
"description": "Subtitle when contributing artist filter is enabled"
|
||||
},
|
||||
"downloadFilterContributingDisabled": "Keep full Album Artist metadata value",
|
||||
"@downloadFilterContributingDisabled": {
|
||||
"description": "Subtitle when contributing artist filter is disabled"
|
||||
},
|
||||
|
||||
"downloadProvidersNoneEnabled": "None enabled",
|
||||
"@downloadProvidersNoneEnabled": {
|
||||
"description": "Subtitle for lyrics providers setting when no providers are enabled"
|
||||
},
|
||||
"downloadMusixmatchLanguageCode": "Language code",
|
||||
"@downloadMusixmatchLanguageCode": {
|
||||
"description": "Label for the Musixmatch language code text field"
|
||||
},
|
||||
"downloadMusixmatchLanguageHint": "auto / en / es / ja",
|
||||
"@downloadMusixmatchLanguageHint": {
|
||||
"description": "Hint text for the Musixmatch language code field"
|
||||
},
|
||||
"downloadMusixmatchLanguageDesc": "Set preferred language code (example: en, es, ja). Leave empty for auto.",
|
||||
"@downloadMusixmatchLanguageDesc": {
|
||||
"description": "Description in the Musixmatch language picker"
|
||||
},
|
||||
"downloadMusixmatchAuto": "Auto",
|
||||
"@downloadMusixmatchAuto": {
|
||||
"description": "Button to reset Musixmatch language to automatic"
|
||||
},
|
||||
|
||||
"downloadNetworkAnySubtitle": "WiFi + Mobile Data",
|
||||
"@downloadNetworkAnySubtitle": {
|
||||
"description": "Subtitle for 'Any' network mode option"
|
||||
},
|
||||
"downloadNetworkWifiOnlySubtitle": "Pause downloads on mobile data",
|
||||
"@downloadNetworkWifiOnlySubtitle": {
|
||||
"description": "Subtitle for 'WiFi only' network mode option"
|
||||
},
|
||||
"downloadSongLinkRegionDesc": "Used as userCountry for SongLink API lookup.",
|
||||
"@downloadSongLinkRegionDesc": {
|
||||
"description": "Description in the SongLink region picker"
|
||||
},
|
||||
"downloadFolderOrganization": "Folder Organization",
|
||||
"@downloadFolderOrganization": {
|
||||
"description": "Title of the folder organization picker bottom sheet"
|
||||
},
|
||||
"snackbarUnsupportedAudioFormat": "Unsupported audio format",
|
||||
"@snackbarUnsupportedAudioFormat": {
|
||||
"description": "Snackbar when the audio format is not supported for the requested operation"
|
||||
},
|
||||
"cacheRefresh": "Refresh",
|
||||
"@cacheRefresh": {
|
||||
"description": "Tooltip for refresh button on cache management page"
|
||||
},
|
||||
"dialogDownloadAllTitle": "Download All",
|
||||
"@dialogDownloadAllTitle": {
|
||||
"description": "Dialog title for bulk download confirmation"
|
||||
},
|
||||
"dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?",
|
||||
"@dialogDownloadPlaylistsMessage": {
|
||||
"description": "Dialog message for bulk playlist download confirmation",
|
||||
"placeholders": {
|
||||
"trackCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"playlistCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}",
|
||||
"@bulkDownloadPlaylistsButton": {
|
||||
"description": "Button label for bulk downloading selected playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bulkDownloadSelectPlaylists": "Select playlists to download",
|
||||
"@bulkDownloadSelectPlaylists": {
|
||||
"description": "Button label when no playlists are selected for download"
|
||||
},
|
||||
"snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks",
|
||||
"@snackbarSelectedPlaylistsEmpty": {
|
||||
"description": "Snackbar when selected playlists contain no tracks"
|
||||
},
|
||||
"playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}",
|
||||
"@playlistsCount": {
|
||||
"description": "Playlist count display",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFill": "Auto-fill from online",
|
||||
"@editMetadataAutoFill": {
|
||||
"description": "Section title for selective online metadata auto-fill in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
|
||||
"@editMetadataAutoFillDesc": {
|
||||
"description": "Description for the auto-fill section"
|
||||
},
|
||||
"editMetadataAutoFillFetch": "Fetch & Fill",
|
||||
"@editMetadataAutoFillFetch": {
|
||||
"description": "Button label to fetch online metadata and fill selected fields"
|
||||
},
|
||||
"editMetadataAutoFillSearching": "Searching online...",
|
||||
"@editMetadataAutoFillSearching": {
|
||||
"description": "Snackbar shown while searching for online metadata"
|
||||
},
|
||||
"editMetadataAutoFillNoResults": "No matching metadata found online",
|
||||
"@editMetadataAutoFillNoResults": {
|
||||
"description": "Snackbar when online metadata search returns no results"
|
||||
},
|
||||
"editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata",
|
||||
"@editMetadataAutoFillDone": {
|
||||
"description": "Snackbar confirming how many fields were auto-filled",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill",
|
||||
"@editMetadataAutoFillNoneSelected": {
|
||||
"description": "Snackbar when user taps Fetch without selecting any fields"
|
||||
},
|
||||
"editMetadataFieldTitle": "Title",
|
||||
"@editMetadataFieldTitle": {
|
||||
"description": "Chip label for title field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldArtist": "Artist",
|
||||
"@editMetadataFieldArtist": {
|
||||
"description": "Chip label for artist field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldAlbum": "Album",
|
||||
"@editMetadataFieldAlbum": {
|
||||
"description": "Chip label for album field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldAlbumArtist": "Album Artist",
|
||||
"@editMetadataFieldAlbumArtist": {
|
||||
"description": "Chip label for album artist field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldDate": "Date",
|
||||
"@editMetadataFieldDate": {
|
||||
"description": "Chip label for date field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldTrackNum": "Track #",
|
||||
"@editMetadataFieldTrackNum": {
|
||||
"description": "Chip label for track number field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldDiscNum": "Disc #",
|
||||
"@editMetadataFieldDiscNum": {
|
||||
"description": "Chip label for disc number field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldGenre": "Genre",
|
||||
"@editMetadataFieldGenre": {
|
||||
"description": "Chip label for genre field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldIsrc": "ISRC",
|
||||
"@editMetadataFieldIsrc": {
|
||||
"description": "Chip label for ISRC field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldLabel": "Label",
|
||||
"@editMetadataFieldLabel": {
|
||||
"description": "Chip label for label field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldCopyright": "Copyright",
|
||||
"@editMetadataFieldCopyright": {
|
||||
"description": "Chip label for copyright field in auto-fill selector"
|
||||
},
|
||||
"editMetadataFieldCover": "Cover Art",
|
||||
"@editMetadataFieldCover": {
|
||||
"description": "Chip label for cover art field in auto-fill selector"
|
||||
},
|
||||
"editMetadataSelectAll": "All",
|
||||
"@editMetadataSelectAll": {
|
||||
"description": "Button to select all fields for auto-fill"
|
||||
},
|
||||
"editMetadataSelectEmpty": "Empty only",
|
||||
"@editMetadataSelectEmpty": {
|
||||
"description": "Button to select only fields that are currently empty"
|
||||
}
|
||||
}
|
||||
|
||||
+409
-3
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -991,10 +1003,26 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
"folderOrganizationNone": "Ninguna organización",
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "Por Artista",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1749,6 +1777,14 @@
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
@@ -2198,6 +2234,15 @@
|
||||
"@libraryAboutDescription": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||
"@libraryTracksUnit": {
|
||||
"description": "Unit label for tracks count (without the number itself)",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
@@ -2358,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2783,6 +2828,367 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
},
|
||||
"collectionFoldersTitle": "My folders",
|
||||
"@collectionFoldersTitle": {
|
||||
"description": "Library section title for custom folders"
|
||||
},
|
||||
"collectionWishlist": "Wishlist",
|
||||
"@collectionWishlist": {
|
||||
"description": "Custom folder for saved tracks to download later"
|
||||
},
|
||||
"collectionLoved": "Loved",
|
||||
"@collectionLoved": {
|
||||
"description": "Custom folder for favorite tracks"
|
||||
},
|
||||
"collectionPlaylists": "Playlists",
|
||||
"@collectionPlaylists": {
|
||||
"description": "Custom user playlists folder"
|
||||
},
|
||||
"collectionPlaylist": "Playlist",
|
||||
"@collectionPlaylist": {
|
||||
"description": "Single playlist label"
|
||||
},
|
||||
"collectionAddToPlaylist": "Add to playlist",
|
||||
"@collectionAddToPlaylist": {
|
||||
"description": "Action to add a track to user playlist"
|
||||
},
|
||||
"collectionCreatePlaylist": "Create playlist",
|
||||
"@collectionCreatePlaylist": {
|
||||
"description": "Action to create a new playlist"
|
||||
},
|
||||
"collectionNoPlaylistsYet": "No playlists yet",
|
||||
"@collectionNoPlaylistsYet": {
|
||||
"description": "Empty state title when user has no playlists"
|
||||
},
|
||||
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||
"@collectionNoPlaylistsSubtitle": {
|
||||
"description": "Empty state subtitle when user has no playlists"
|
||||
},
|
||||
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@collectionPlaylistTracks": {
|
||||
"description": "Track count label for custom playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||
"@collectionAddedToPlaylist": {
|
||||
"description": "Snackbar after adding track to playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||
"@collectionAlreadyInPlaylist": {
|
||||
"description": "Snackbar when track already exists in playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistCreated": "Playlist created",
|
||||
"@collectionPlaylistCreated": {
|
||||
"description": "Snackbar after creating playlist"
|
||||
},
|
||||
"collectionPlaylistNameHint": "Playlist name",
|
||||
"@collectionPlaylistNameHint": {
|
||||
"description": "Hint text for playlist name input"
|
||||
},
|
||||
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||
"@collectionPlaylistNameRequired": {
|
||||
"description": "Validation error for empty playlist name"
|
||||
},
|
||||
"collectionRenamePlaylist": "Rename playlist",
|
||||
"@collectionRenamePlaylist": {
|
||||
"description": "Action to rename playlist"
|
||||
},
|
||||
"collectionDeletePlaylist": "Delete playlist",
|
||||
"@collectionDeletePlaylist": {
|
||||
"description": "Action to delete playlist"
|
||||
},
|
||||
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||
"@collectionDeletePlaylistMessage": {
|
||||
"description": "Confirmation message for deleting playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistDeleted": "Playlist deleted",
|
||||
"@collectionPlaylistDeleted": {
|
||||
"description": "Snackbar after deleting playlist"
|
||||
},
|
||||
"collectionPlaylistRenamed": "Playlist renamed",
|
||||
"@collectionPlaylistRenamed": {
|
||||
"description": "Snackbar after renaming playlist"
|
||||
},
|
||||
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||
"@collectionWishlistEmptyTitle": {
|
||||
"description": "Wishlist empty state title"
|
||||
},
|
||||
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||
"@collectionWishlistEmptySubtitle": {
|
||||
"description": "Wishlist empty state subtitle"
|
||||
},
|
||||
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||
"@collectionLovedEmptyTitle": {
|
||||
"description": "Loved empty state title"
|
||||
},
|
||||
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||
"@collectionLovedEmptySubtitle": {
|
||||
"description": "Loved empty state subtitle"
|
||||
},
|
||||
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||
"@collectionPlaylistEmptyTitle": {
|
||||
"description": "Playlist empty state title"
|
||||
},
|
||||
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||
"@collectionPlaylistEmptySubtitle": {
|
||||
"description": "Playlist empty state subtitle"
|
||||
},
|
||||
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||
"@collectionRemoveFromPlaylist": {
|
||||
"description": "Tooltip for removing track from playlist"
|
||||
},
|
||||
"collectionRemoveFromFolder": "Remove from folder",
|
||||
"@collectionRemoveFromFolder": {
|
||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||
},
|
||||
"collectionRemoved": "\"{trackName}\" removed",
|
||||
"@collectionRemoved": {
|
||||
"description": "Snackbar after removing a track from a collection",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||
"@collectionAddedToLoved": {
|
||||
"description": "Snackbar after adding track to loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||
"@collectionRemovedFromLoved": {
|
||||
"description": "Snackbar after removing track from loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||
"@collectionAddedToWishlist": {
|
||||
"description": "Snackbar after adding track to wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||
"@collectionRemovedFromWishlist": {
|
||||
"description": "Snackbar after removing track from wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackOptionAddToLoved": "Add to Loved",
|
||||
"@trackOptionAddToLoved": {
|
||||
"description": "Bottom sheet action label - add track to loved folder"
|
||||
},
|
||||
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||
"@trackOptionRemoveFromLoved": {
|
||||
"description": "Bottom sheet action label - remove track from loved folder"
|
||||
},
|
||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||
"@trackOptionAddToWishlist": {
|
||||
"description": "Bottom sheet action label - add track to wishlist"
|
||||
},
|
||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||
"@trackOptionRemoveFromWishlist": {
|
||||
"description": "Bottom sheet action label - remove track from wishlist"
|
||||
},
|
||||
"collectionPlaylistChangeCover": "Change cover image",
|
||||
"@collectionPlaylistChangeCover": {
|
||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||
},
|
||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||
"@collectionPlaylistRemoveCover": {
|
||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||
},
|
||||
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionShareCount": {
|
||||
"description": "Share button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionShareNoFiles": "No shareable files found",
|
||||
"@selectionShareNoFiles": {
|
||||
"description": "Snackbar when no selected files exist on disk"
|
||||
},
|
||||
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionConvertCount": {
|
||||
"description": "Convert button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||
"@selectionConvertNoConvertible": {
|
||||
"description": "Snackbar when no selected tracks support conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||
"@selectionBatchConvertConfirmTitle": {
|
||||
"description": "Confirmation dialog title for batch conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||
"@selectionBatchConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message for batch conversion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
},
|
||||
"bitrate": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||
"@selectionBatchConvertSuccess": {
|
||||
"description": "Snackbar after batch conversion completes",
|
||||
"placeholders": {
|
||||
"success": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumDownloadedCount": "{count} descargado",
|
||||
"@downloadedAlbumDownloadedCount": {
|
||||
"description": "Downloaded tracks count badge",
|
||||
@@ -2800,4 +3206,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
-3
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "By Artist",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
|
||||
+107
-3
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "By Artist",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
|
||||
+221
-56
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
|
||||
"aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -1003,11 +1003,11 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||
"filenameShowAdvancedTagsDescription": "Aktifkan tag yang diformat untuk padding trek dan pola tanggal",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
@@ -1015,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "Berdasarkan Daftar Putar",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Setiap daftar putar memerlukan folder terpisah",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "Berdasarkan Artis",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1109,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Bawaan",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Ekstensi",
|
||||
"@providerExtension": {
|
||||
@@ -1209,7 +1217,7 @@
|
||||
"@credentialsDescription": {
|
||||
"description": "Credentials dialog explanation"
|
||||
},
|
||||
"credentialsClientId": "Client ID",
|
||||
"credentialsClientId": "ID Klien",
|
||||
"@credentialsClientId": {
|
||||
"description": "Client ID field label - DO NOT TRANSLATE"
|
||||
},
|
||||
@@ -1217,7 +1225,7 @@
|
||||
"@credentialsClientIdHint": {
|
||||
"description": "Client ID placeholder"
|
||||
},
|
||||
"credentialsClientSecret": "Client Secret",
|
||||
"credentialsClientSecret": "Rahasia Klien",
|
||||
"@credentialsClientSecret": {
|
||||
"description": "Client Secret field label - DO NOT TRANSLATE"
|
||||
},
|
||||
@@ -1229,7 +1237,7 @@
|
||||
"@channelStable": {
|
||||
"description": "Update channel - stable releases"
|
||||
},
|
||||
"channelPreview": "Preview",
|
||||
"channelPreview": "Pratinjau",
|
||||
"@channelPreview": {
|
||||
"description": "Update channel - beta/preview releases"
|
||||
},
|
||||
@@ -1269,39 +1277,39 @@
|
||||
"@sectionFileSettings": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"sectionLyrics": "Lyrics",
|
||||
"sectionLyrics": "Lirik",
|
||||
"@sectionLyrics": {
|
||||
"description": "Settings section header"
|
||||
},
|
||||
"lyricsMode": "Lyrics Mode",
|
||||
"lyricsMode": "Mode Lirik",
|
||||
"@lyricsMode": {
|
||||
"description": "Setting - how to save lyrics"
|
||||
},
|
||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
||||
"lyricsModeDescription": "Pilih cara lirik disimpan bersama unduhan Anda",
|
||||
"@lyricsModeDescription": {
|
||||
"description": "Lyrics mode picker description"
|
||||
},
|
||||
"lyricsModeEmbed": "Embed in file",
|
||||
"lyricsModeEmbed": "Sematkan dalam file",
|
||||
"@lyricsModeEmbed": {
|
||||
"description": "Lyrics mode option - embed in audio file"
|
||||
},
|
||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
||||
"lyricsModeEmbedSubtitle": "Lirik tersimpan di dalam metadata FLAC",
|
||||
"@lyricsModeEmbedSubtitle": {
|
||||
"description": "Subtitle for embed option"
|
||||
},
|
||||
"lyricsModeExternal": "External .lrc file",
|
||||
"lyricsModeExternal": "File .lrc eksternal",
|
||||
"@lyricsModeExternal": {
|
||||
"description": "Lyrics mode option - separate LRC file"
|
||||
},
|
||||
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music",
|
||||
"lyricsModeExternalSubtitle": "File .lrc terpisah untuk pemutar musik seperti Samsung Music",
|
||||
"@lyricsModeExternalSubtitle": {
|
||||
"description": "Subtitle for external option"
|
||||
},
|
||||
"lyricsModeBoth": "Both",
|
||||
"lyricsModeBoth": "Keduanya",
|
||||
"@lyricsModeBoth": {
|
||||
"description": "Lyrics mode option - embed and external"
|
||||
},
|
||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
||||
"lyricsModeBothSubtitle": "Sematkan dan simpan file .lrc",
|
||||
"@lyricsModeBothSubtitle": {
|
||||
"description": "Subtitle for both option"
|
||||
},
|
||||
@@ -1447,11 +1455,11 @@
|
||||
"@trackGenre": {
|
||||
"description": "Metadata label - music genre"
|
||||
},
|
||||
"trackLabel": "Label",
|
||||
"trackLabel": "Lebel",
|
||||
"@trackLabel": {
|
||||
"description": "Metadata label - record label"
|
||||
},
|
||||
"trackCopyright": "Copyright",
|
||||
"trackCopyright": "Hak cipta",
|
||||
"@trackCopyright": {
|
||||
"description": "Metadata label - copyright information"
|
||||
},
|
||||
@@ -1475,15 +1483,15 @@
|
||||
"@trackLyricsLoadFailed": {
|
||||
"description": "Message when lyrics loading fails"
|
||||
},
|
||||
"trackEmbedLyrics": "Embed Lyrics",
|
||||
"trackEmbedLyrics": "Sematkan Lirik",
|
||||
"@trackEmbedLyrics": {
|
||||
"description": "Action - embed lyrics into audio file"
|
||||
},
|
||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
||||
"trackLyricsEmbedded": "Lirik berhasil disematkan",
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"trackInstrumental": "Lagu instrumental",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
@@ -1562,7 +1570,7 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Bawaan (Deezer/Spotify)",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
@@ -1578,7 +1586,7 @@
|
||||
"@extensionId": {
|
||||
"description": "Extension detail - unique ID"
|
||||
},
|
||||
"extensionError": "Error",
|
||||
"extensionError": "Terjadi kesalahan",
|
||||
"@extensionError": {
|
||||
"description": "Extension detail - error message"
|
||||
},
|
||||
@@ -1765,15 +1773,15 @@
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"youtubeQualityNote": "YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"youtubeOpusBitrateTitle": "Bitrate YouTube Opus",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"youtubeMp3BitrateTitle": "Kecepatan Bit MP3 YouTube",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
@@ -1793,19 +1801,35 @@
|
||||
"@downloadAlbumFolderStructure": {
|
||||
"description": "Setting - album folder organization"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
|
||||
"downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist",
|
||||
"@downloadCreatePlaylistSourceFolder": {
|
||||
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.",
|
||||
"@downloadCreatePlaylistSourceFolderEnabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is enabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.",
|
||||
"@downloadCreatePlaylistSourceFolderDisabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is disabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.",
|
||||
"@downloadCreatePlaylistSourceFolderRedundant": {
|
||||
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
|
||||
"@downloadUsePrimaryArtistOnly": {
|
||||
"description": "Setting - strip featured artists from folder name"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)",
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||
"description": "Subtitle when primary artist only is enabled"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
|
||||
"downloadUsePrimaryArtistOnlyDisabled": "Nama lengkap artis digunakan untuk nama folder",
|
||||
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||
"description": "Subtitle when primary artist only is disabled"
|
||||
},
|
||||
@@ -1833,27 +1857,27 @@
|
||||
"@queueClearAllMessage": {
|
||||
"description": "Clear queue confirmation"
|
||||
},
|
||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
||||
"settingsAutoExportFailed": "Unduhan yang gagal diekspor otomatis",
|
||||
"@settingsAutoExportFailed": {
|
||||
"description": "Setting toggle for auto-export"
|
||||
},
|
||||
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
|
||||
"settingsAutoExportFailedSubtitle": "Simpan unduhan yang gagal ke file TXT secara otomatis",
|
||||
"@settingsAutoExportFailedSubtitle": {
|
||||
"description": "Subtitle for auto-export setting"
|
||||
},
|
||||
"settingsDownloadNetwork": "Download Network",
|
||||
"settingsDownloadNetwork": "Jaringan Unduhan",
|
||||
"@settingsDownloadNetwork": {
|
||||
"description": "Setting for network type preference"
|
||||
},
|
||||
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
||||
"settingsDownloadNetworkAny": "WiFi + Data Seluler",
|
||||
"@settingsDownloadNetworkAny": {
|
||||
"description": "Network option - use any connection"
|
||||
},
|
||||
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
||||
"settingsDownloadNetworkWifiOnly": "Hanya WiFi",
|
||||
"@settingsDownloadNetworkWifiOnly": {
|
||||
"description": "Network option - only use WiFi"
|
||||
},
|
||||
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.",
|
||||
"settingsDownloadNetworkSubtitle": "Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.",
|
||||
"@settingsDownloadNetworkSubtitle": {
|
||||
"description": "Subtitle explaining network preference"
|
||||
},
|
||||
@@ -1889,11 +1913,11 @@
|
||||
"@albumFolderYearAlbumSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
||||
"albumFolderArtistAlbumSingles": "Artis / Album + Singel",
|
||||
"@albumFolderArtistAlbumSingles": {
|
||||
"description": "Album folder option with singles inside artist"
|
||||
},
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
||||
"albumFolderArtistAlbumSinglesSubtitle": "Artis/Album/ dan Artis/Single/",
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
@@ -1962,19 +1986,19 @@
|
||||
"@recentTypeSong": {
|
||||
"description": "Recent access item type - song/track"
|
||||
},
|
||||
"recentTypePlaylist": "Playlist",
|
||||
"recentTypePlaylist": "Daftar putar",
|
||||
"@recentTypePlaylist": {
|
||||
"description": "Recent access item type - playlist"
|
||||
},
|
||||
"recentEmpty": "No recent items yet",
|
||||
"recentEmpty": "Belum ada item terbaru",
|
||||
"@recentEmpty": {
|
||||
"description": "Empty state text for recent access list"
|
||||
},
|
||||
"recentShowAllDownloads": "Show All Downloads",
|
||||
"recentShowAllDownloads": "Tampilkan Semua Unduhan",
|
||||
"@recentShowAllDownloads": {
|
||||
"description": "Button label to unhide hidden downloads in recent access"
|
||||
},
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"recentPlaylistInfo": "Daftar Putar: {name}",
|
||||
"@recentPlaylistInfo": {
|
||||
"description": "Snackbar message when tapping playlist in recent access",
|
||||
"placeholders": {
|
||||
@@ -1984,7 +2008,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"discographyDownload": "Download Discography",
|
||||
"discographyDownload": "Unduh Diskografi",
|
||||
"@discographyDownload": {
|
||||
"description": "Button - download artist discography"
|
||||
},
|
||||
@@ -2383,47 +2407,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
||||
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
|
||||
"@tutorialWelcomeTitle": {
|
||||
"description": "Tutorial welcome page title"
|
||||
},
|
||||
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
|
||||
"tutorialWelcomeDesc": "Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
|
||||
"@tutorialWelcomeDesc": {
|
||||
"description": "Tutorial welcome page description"
|
||||
},
|
||||
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
|
||||
"tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
|
||||
"tutorialWelcomeTip3": "Penyematan metadata, sampul album, dan lirik secara otomatis",
|
||||
"@tutorialWelcomeTip3": {
|
||||
"description": "Tutorial welcome tip 3"
|
||||
},
|
||||
"tutorialSearchTitle": "Finding Music",
|
||||
"tutorialSearchTitle": "Menemukan Musik",
|
||||
"@tutorialSearchTitle": {
|
||||
"description": "Tutorial search page title"
|
||||
},
|
||||
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
|
||||
"tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
|
||||
"@tutorialSearchDesc": {
|
||||
"description": "Tutorial search page description"
|
||||
},
|
||||
"tutorialDownloadTitle": "Downloading Music",
|
||||
"tutorialDownloadTitle": "Mengunduh Musik",
|
||||
"@tutorialDownloadTitle": {
|
||||
"description": "Tutorial download page title"
|
||||
},
|
||||
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
|
||||
"tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.",
|
||||
"@tutorialDownloadDesc": {
|
||||
"description": "Tutorial download page description"
|
||||
},
|
||||
"tutorialLibraryTitle": "Your Library",
|
||||
"tutorialLibraryTitle": "Perpustakaan Anda",
|
||||
"@tutorialLibraryTitle": {
|
||||
"description": "Tutorial library page title"
|
||||
},
|
||||
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
|
||||
"tutorialLibraryDesc": "Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.",
|
||||
"@tutorialLibraryDesc": {
|
||||
"description": "Tutorial library page description"
|
||||
},
|
||||
@@ -2755,6 +2779,47 @@
|
||||
"@trackReEnrichFfmpegFailed": {
|
||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
||||
},
|
||||
"queueFlacAction": "Antrekan FLAC",
|
||||
"@queueFlacAction": {
|
||||
"description": "Action/button label for queueing FLAC redownloads for local tracks"
|
||||
},
|
||||
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
|
||||
"@queueFlacConfirmMessage": {
|
||||
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
|
||||
"@queueFlacFindingProgress": {
|
||||
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
|
||||
"@queueFlacNoReliableMatches": {
|
||||
"description": "Snackbar when no safe FLAC redownload matches were found"
|
||||
},
|
||||
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
|
||||
"@queueFlacQueuedWithSkipped": {
|
||||
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
|
||||
"placeholders": {
|
||||
"addedCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"skippedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackSaveFailed": "Failed: {error}",
|
||||
"@trackSaveFailed": {
|
||||
"description": "Snackbar when save operation fails",
|
||||
@@ -2768,7 +2833,7 @@
|
||||
"@trackConvertFormat": {
|
||||
"description": "Menu item - convert audio format"
|
||||
},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
||||
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
|
||||
"@trackConvertFormatSubtitle": {
|
||||
"description": "Subtitle for convert format menu item"
|
||||
},
|
||||
@@ -2803,6 +2868,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
|
||||
"@trackConvertConfirmMessageLossless": {
|
||||
"description": "Confirmation dialog message for lossless-to-lossless conversion",
|
||||
"placeholders": {
|
||||
"sourceFormat": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetFormat": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
|
||||
"@trackConvertLosslessHint": {
|
||||
"description": "Hint shown when converting between lossless formats"
|
||||
},
|
||||
"trackConvertConverting": "Converting audio...",
|
||||
"@trackConvertConverting": {
|
||||
"description": "Snackbar while converting"
|
||||
@@ -2820,6 +2901,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
@@ -3114,4 +3279,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+116
-12
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "{item} を読み込めません: 拡張ソースがありません",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -991,7 +1003,7 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"filenameShowAdvancedTags": "高度なタグを表示",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "アーティスト別",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "内蔵",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "拡張",
|
||||
"@providerExtension": {
|
||||
@@ -1471,7 +1491,7 @@
|
||||
"@trackLyricsEmbedded": {
|
||||
"description": "Snackbar - lyrics saved to file"
|
||||
},
|
||||
"trackInstrumental": "Instrumental track",
|
||||
"trackInstrumental": "インストゥルメンタルのトラック",
|
||||
"@trackInstrumental": {
|
||||
"description": "Message when track is instrumental (no lyrics)"
|
||||
},
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "分割 CUE シート",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
@@ -2940,7 +3044,7 @@
|
||||
"@collectionRemoveFromPlaylist": {
|
||||
"description": "Tooltip for removing track from playlist"
|
||||
},
|
||||
"collectionRemoveFromFolder": "Remove from folder",
|
||||
"collectionRemoveFromFolder": "フォルダから削除",
|
||||
"@collectionRemoveFromFolder": {
|
||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||
},
|
||||
@@ -2997,23 +3101,23 @@
|
||||
"@trackOptionRemoveFromLoved": {
|
||||
"description": "Bottom sheet action label - remove track from loved folder"
|
||||
},
|
||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||
"trackOptionAddToWishlist": "ウィッシュリストに追加",
|
||||
"@trackOptionAddToWishlist": {
|
||||
"description": "Bottom sheet action label - add track to wishlist"
|
||||
},
|
||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||
"trackOptionRemoveFromWishlist": "ウィッシュから削除",
|
||||
"@trackOptionRemoveFromWishlist": {
|
||||
"description": "Bottom sheet action label - remove track from wishlist"
|
||||
},
|
||||
"collectionPlaylistChangeCover": "Change cover image",
|
||||
"collectionPlaylistChangeCover": "カバー画像を変更",
|
||||
"@collectionPlaylistChangeCover": {
|
||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||
},
|
||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||
"collectionPlaylistRemoveCover": "カバー画像を削除",
|
||||
"@collectionPlaylistRemoveCover": {
|
||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||
},
|
||||
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||
"selectionShareCount": "{count} {count, plural, =1{個のトラック} other{個のトラック}}を共有",
|
||||
"@selectionShareCount": {
|
||||
"description": "Share button text with count in selection mode",
|
||||
"placeholders": {
|
||||
@@ -3039,7 +3143,7 @@
|
||||
"@selectionConvertNoConvertible": {
|
||||
"description": "Snackbar when no selected tracks support conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||
"selectionBatchConvertConfirmTitle": "一括変換",
|
||||
"@selectionBatchConvertConfirmTitle": {
|
||||
"description": "Confirmation dialog title for batch conversion"
|
||||
},
|
||||
|
||||
+107
-3
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "By Artist",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
|
||||
+111
-7
@@ -194,11 +194,11 @@
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
},
|
||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||
"optionsConcurrentSequential": "Sequentiële (1 per keer)",
|
||||
"@optionsConcurrentSequential": {
|
||||
"description": "Download one at a time"
|
||||
},
|
||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||
"optionsConcurrentParallel": "",
|
||||
"@optionsConcurrentParallel": {
|
||||
"description": "Multiple parallel downloads",
|
||||
"placeholders": {
|
||||
@@ -207,7 +207,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||
"optionsConcurrentWarning": "Parallel downloaden kan leiden tot rate-limiting",
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
@@ -346,7 +346,7 @@
|
||||
"@aboutContributors": {
|
||||
"description": "Section for contributors"
|
||||
},
|
||||
"aboutMobileDeveloper": "Mobile version developer",
|
||||
"aboutMobileDeveloper": "",
|
||||
"@aboutMobileDeveloper": {
|
||||
"description": "Role description for mobile dev"
|
||||
},
|
||||
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "By Artist",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
|
||||
+409
-3
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Não é possível carregar {item}: faltando a fonte da extensão",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -991,10 +1003,26 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
"folderOrganizationNone": "Nenhuma organização",
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "Por Artista",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1749,6 +1777,14 @@
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
@@ -2198,6 +2234,15 @@
|
||||
"@libraryAboutDescription": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||
"@libraryTracksUnit": {
|
||||
"description": "Unit label for tracks count (without the number itself)",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
@@ -2358,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2783,6 +2828,367 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
},
|
||||
"collectionFoldersTitle": "My folders",
|
||||
"@collectionFoldersTitle": {
|
||||
"description": "Library section title for custom folders"
|
||||
},
|
||||
"collectionWishlist": "Wishlist",
|
||||
"@collectionWishlist": {
|
||||
"description": "Custom folder for saved tracks to download later"
|
||||
},
|
||||
"collectionLoved": "Loved",
|
||||
"@collectionLoved": {
|
||||
"description": "Custom folder for favorite tracks"
|
||||
},
|
||||
"collectionPlaylists": "Playlists",
|
||||
"@collectionPlaylists": {
|
||||
"description": "Custom user playlists folder"
|
||||
},
|
||||
"collectionPlaylist": "Playlist",
|
||||
"@collectionPlaylist": {
|
||||
"description": "Single playlist label"
|
||||
},
|
||||
"collectionAddToPlaylist": "Add to playlist",
|
||||
"@collectionAddToPlaylist": {
|
||||
"description": "Action to add a track to user playlist"
|
||||
},
|
||||
"collectionCreatePlaylist": "Create playlist",
|
||||
"@collectionCreatePlaylist": {
|
||||
"description": "Action to create a new playlist"
|
||||
},
|
||||
"collectionNoPlaylistsYet": "No playlists yet",
|
||||
"@collectionNoPlaylistsYet": {
|
||||
"description": "Empty state title when user has no playlists"
|
||||
},
|
||||
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||
"@collectionNoPlaylistsSubtitle": {
|
||||
"description": "Empty state subtitle when user has no playlists"
|
||||
},
|
||||
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@collectionPlaylistTracks": {
|
||||
"description": "Track count label for custom playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||
"@collectionAddedToPlaylist": {
|
||||
"description": "Snackbar after adding track to playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||
"@collectionAlreadyInPlaylist": {
|
||||
"description": "Snackbar when track already exists in playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistCreated": "Playlist created",
|
||||
"@collectionPlaylistCreated": {
|
||||
"description": "Snackbar after creating playlist"
|
||||
},
|
||||
"collectionPlaylistNameHint": "Playlist name",
|
||||
"@collectionPlaylistNameHint": {
|
||||
"description": "Hint text for playlist name input"
|
||||
},
|
||||
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||
"@collectionPlaylistNameRequired": {
|
||||
"description": "Validation error for empty playlist name"
|
||||
},
|
||||
"collectionRenamePlaylist": "Rename playlist",
|
||||
"@collectionRenamePlaylist": {
|
||||
"description": "Action to rename playlist"
|
||||
},
|
||||
"collectionDeletePlaylist": "Delete playlist",
|
||||
"@collectionDeletePlaylist": {
|
||||
"description": "Action to delete playlist"
|
||||
},
|
||||
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||
"@collectionDeletePlaylistMessage": {
|
||||
"description": "Confirmation message for deleting playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistDeleted": "Playlist deleted",
|
||||
"@collectionPlaylistDeleted": {
|
||||
"description": "Snackbar after deleting playlist"
|
||||
},
|
||||
"collectionPlaylistRenamed": "Playlist renamed",
|
||||
"@collectionPlaylistRenamed": {
|
||||
"description": "Snackbar after renaming playlist"
|
||||
},
|
||||
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||
"@collectionWishlistEmptyTitle": {
|
||||
"description": "Wishlist empty state title"
|
||||
},
|
||||
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||
"@collectionWishlistEmptySubtitle": {
|
||||
"description": "Wishlist empty state subtitle"
|
||||
},
|
||||
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||
"@collectionLovedEmptyTitle": {
|
||||
"description": "Loved empty state title"
|
||||
},
|
||||
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||
"@collectionLovedEmptySubtitle": {
|
||||
"description": "Loved empty state subtitle"
|
||||
},
|
||||
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||
"@collectionPlaylistEmptyTitle": {
|
||||
"description": "Playlist empty state title"
|
||||
},
|
||||
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||
"@collectionPlaylistEmptySubtitle": {
|
||||
"description": "Playlist empty state subtitle"
|
||||
},
|
||||
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||
"@collectionRemoveFromPlaylist": {
|
||||
"description": "Tooltip for removing track from playlist"
|
||||
},
|
||||
"collectionRemoveFromFolder": "Remove from folder",
|
||||
"@collectionRemoveFromFolder": {
|
||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||
},
|
||||
"collectionRemoved": "\"{trackName}\" removed",
|
||||
"@collectionRemoved": {
|
||||
"description": "Snackbar after removing a track from a collection",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||
"@collectionAddedToLoved": {
|
||||
"description": "Snackbar after adding track to loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||
"@collectionRemovedFromLoved": {
|
||||
"description": "Snackbar after removing track from loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||
"@collectionAddedToWishlist": {
|
||||
"description": "Snackbar after adding track to wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||
"@collectionRemovedFromWishlist": {
|
||||
"description": "Snackbar after removing track from wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackOptionAddToLoved": "Add to Loved",
|
||||
"@trackOptionAddToLoved": {
|
||||
"description": "Bottom sheet action label - add track to loved folder"
|
||||
},
|
||||
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||
"@trackOptionRemoveFromLoved": {
|
||||
"description": "Bottom sheet action label - remove track from loved folder"
|
||||
},
|
||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||
"@trackOptionAddToWishlist": {
|
||||
"description": "Bottom sheet action label - add track to wishlist"
|
||||
},
|
||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||
"@trackOptionRemoveFromWishlist": {
|
||||
"description": "Bottom sheet action label - remove track from wishlist"
|
||||
},
|
||||
"collectionPlaylistChangeCover": "Change cover image",
|
||||
"@collectionPlaylistChangeCover": {
|
||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||
},
|
||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||
"@collectionPlaylistRemoveCover": {
|
||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||
},
|
||||
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionShareCount": {
|
||||
"description": "Share button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionShareNoFiles": "No shareable files found",
|
||||
"@selectionShareNoFiles": {
|
||||
"description": "Snackbar when no selected files exist on disk"
|
||||
},
|
||||
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionConvertCount": {
|
||||
"description": "Convert button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||
"@selectionConvertNoConvertible": {
|
||||
"description": "Snackbar when no selected tracks support conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||
"@selectionBatchConvertConfirmTitle": {
|
||||
"description": "Confirmation dialog title for batch conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||
"@selectionBatchConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message for batch conversion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
},
|
||||
"bitrate": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||
"@selectionBatchConvertSuccess": {
|
||||
"description": "Snackbar after batch conversion completes",
|
||||
"placeholders": {
|
||||
"success": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumDownloadedCount": "{count} baixado(s)",
|
||||
"@downloadedAlbumDownloadedCount": {
|
||||
"description": "Downloaded tracks count badge",
|
||||
@@ -2800,4 +3206,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
-10
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.",
|
||||
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Ссылка не распознана",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Невозможно загрузить {item}: отсутствует источник расширения",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "По плейлисту",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Отдельная папка для каждого плейлиста",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "По исполнителю",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Встроенные",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Расширение",
|
||||
"@providerExtension": {
|
||||
@@ -1789,7 +1809,7 @@
|
||||
"@downloadUsePrimaryArtistOnly": {
|
||||
"description": "Setting - strip featured artists from folder name"
|
||||
},
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
|
||||
"downloadUsePrimaryArtistOnlyEnabled": "Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)",
|
||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||
"description": "Subtitle when primary artist only is enabled"
|
||||
},
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Amazon Music",
|
||||
"tutorialWelcomeTip2": "Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2487,7 +2507,7 @@
|
||||
"@cleanupOrphanedDownloadsSubtitle": {
|
||||
"description": "Subtitle for orphaned cleanup button"
|
||||
},
|
||||
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
||||
"cleanupOrphanedDownloadsResult": "Удалено {count} утерянных записей из истории",
|
||||
"@cleanupOrphanedDownloadsResult": {
|
||||
"description": "Snackbar after orphan cleanup",
|
||||
"placeholders": {
|
||||
@@ -2525,7 +2545,7 @@
|
||||
"@cacheSectionStorage": {
|
||||
"description": "Section header for cache entries"
|
||||
},
|
||||
"cacheSectionMaintenance": "Maintenance",
|
||||
"cacheSectionMaintenance": "Обслуживание",
|
||||
"@cacheSectionMaintenance": {
|
||||
"description": "Section header for cleanup actions"
|
||||
},
|
||||
@@ -2577,7 +2597,7 @@
|
||||
"@cacheTrackLookupDesc": {
|
||||
"description": "Description of what track lookup cache contains"
|
||||
},
|
||||
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
|
||||
"cacheCleanupUnusedDesc": "Удалить записи из истории загрузок и библиотеки, которые остались без файлов.",
|
||||
"@cacheCleanupUnusedDesc": {
|
||||
"description": "Description of what cleanup unused data does"
|
||||
},
|
||||
@@ -2653,7 +2673,7 @@
|
||||
"@cacheCleanupUnused": {
|
||||
"description": "Action title for cleaning unused entries"
|
||||
},
|
||||
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
|
||||
"cacheCleanupUnusedSubtitle": "Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке",
|
||||
"@cacheCleanupUnusedSubtitle": {
|
||||
"description": "Subtitle for cleanup unused data action"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Разделить CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Разделить файл CUE+FLAC на отдельные треки",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Альбом: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Артист: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} треков",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Разделенный CUE-альбом",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Разбить \"{album}\" на {count} отдельных FLAC-файлов?",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Разделение CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Успешно разделено на {count} треков",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "Разделение CUE не удалось",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Аудиофайл для этого CUE sheet не найден",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Разделить на Треки",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Создать",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
@@ -3022,7 +3126,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionShareNoFiles": "No shareable files found",
|
||||
"selectionShareNoFiles": "Файлы, доступные для совместного доступа, не найдены",
|
||||
"@selectionShareNoFiles": {
|
||||
"description": "Snackbar when no selected files exist on disk"
|
||||
},
|
||||
@@ -3043,7 +3147,7 @@
|
||||
"@selectionBatchConvertConfirmTitle": {
|
||||
"description": "Confirmation dialog title for batch conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||
"selectionBatchConvertConfirmMessage": "Преобразовать {count} {count, plural, =1{track} other{tracks}} в {format} с {bitrate}?",
|
||||
"@selectionBatchConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message for batch conversion",
|
||||
"placeholders": {
|
||||
|
||||
+544
-138
File diff suppressed because it is too large
Load Diff
+203
-99
@@ -5,143 +5,143 @@
|
||||
"@appName": {
|
||||
"description": "App name - DO NOT TRANSLATE"
|
||||
},
|
||||
"navHome": "Home",
|
||||
"navHome": "主页",
|
||||
"@navHome": {
|
||||
"description": "Bottom navigation - Home tab"
|
||||
},
|
||||
"navLibrary": "Library",
|
||||
"navLibrary": "乐库",
|
||||
"@navLibrary": {
|
||||
"description": "Bottom navigation - Library tab"
|
||||
},
|
||||
"navSettings": "Settings",
|
||||
"navSettings": "设置",
|
||||
"@navSettings": {
|
||||
"description": "Bottom navigation - Settings tab"
|
||||
},
|
||||
"navStore": "Store",
|
||||
"navStore": "商店",
|
||||
"@navStore": {
|
||||
"description": "Bottom navigation - Extension store tab"
|
||||
},
|
||||
"homeTitle": "Home",
|
||||
"homeTitle": "主页",
|
||||
"@homeTitle": {
|
||||
"description": "Home screen title"
|
||||
},
|
||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
||||
"homeSubtitle": "粘贴 Spotify 链接或按名称搜索",
|
||||
"@homeSubtitle": {
|
||||
"description": "Subtitle shown below search box"
|
||||
},
|
||||
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
|
||||
"homeSupports": "支持:歌曲、专辑、播放列表、艺人网址",
|
||||
"@homeSupports": {
|
||||
"description": "Info text about supported URL types"
|
||||
},
|
||||
"homeRecent": "Recent",
|
||||
"homeRecent": "最近",
|
||||
"@homeRecent": {
|
||||
"description": "Section header for recent searches"
|
||||
},
|
||||
"historyFilterAll": "All",
|
||||
"historyFilterAll": "全部",
|
||||
"@historyFilterAll": {
|
||||
"description": "Filter chip - show all items"
|
||||
},
|
||||
"historyFilterAlbums": "Albums",
|
||||
"historyFilterAlbums": "专辑",
|
||||
"@historyFilterAlbums": {
|
||||
"description": "Filter chip - show albums only"
|
||||
},
|
||||
"historyFilterSingles": "Singles",
|
||||
"historyFilterSingles": "单曲",
|
||||
"@historyFilterSingles": {
|
||||
"description": "Filter chip - show singles only"
|
||||
},
|
||||
"historySearchHint": "Search history...",
|
||||
"historySearchHint": "搜索历史……",
|
||||
"@historySearchHint": {
|
||||
"description": "Search bar placeholder in history"
|
||||
},
|
||||
"settingsTitle": "Settings",
|
||||
"settingsTitle": "设置",
|
||||
"@settingsTitle": {
|
||||
"description": "Settings screen title"
|
||||
},
|
||||
"settingsDownload": "Download",
|
||||
"settingsDownload": "下载",
|
||||
"@settingsDownload": {
|
||||
"description": "Settings section - download options"
|
||||
},
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsAppearance": "外观",
|
||||
"@settingsAppearance": {
|
||||
"description": "Settings section - visual customization"
|
||||
},
|
||||
"settingsOptions": "Options",
|
||||
"settingsOptions": "选项",
|
||||
"@settingsOptions": {
|
||||
"description": "Settings section - app options"
|
||||
},
|
||||
"settingsExtensions": "Extensions",
|
||||
"settingsExtensions": "扩展",
|
||||
"@settingsExtensions": {
|
||||
"description": "Settings section - extension management"
|
||||
},
|
||||
"settingsAbout": "About",
|
||||
"settingsAbout": "关于",
|
||||
"@settingsAbout": {
|
||||
"description": "Settings section - app info"
|
||||
},
|
||||
"downloadTitle": "Download",
|
||||
"downloadTitle": "下载",
|
||||
"@downloadTitle": {
|
||||
"description": "Download settings page title"
|
||||
},
|
||||
"downloadAskQualitySubtitle": "Show quality picker for each download",
|
||||
"downloadAskQualitySubtitle": "为每次下载显示质量选择器",
|
||||
"@downloadAskQualitySubtitle": {
|
||||
"description": "Subtitle for ask quality toggle"
|
||||
},
|
||||
"downloadFilenameFormat": "Filename Format",
|
||||
"downloadFilenameFormat": "文件名格式",
|
||||
"@downloadFilenameFormat": {
|
||||
"description": "Setting for output filename pattern"
|
||||
},
|
||||
"downloadFolderOrganization": "Folder Organization",
|
||||
"downloadFolderOrganization": "文件夹结构",
|
||||
"@downloadFolderOrganization": {
|
||||
"description": "Setting for folder structure"
|
||||
},
|
||||
"appearanceTitle": "Appearance",
|
||||
"appearanceTitle": "外观",
|
||||
"@appearanceTitle": {
|
||||
"description": "Appearance settings page title"
|
||||
},
|
||||
"appearanceThemeSystem": "System",
|
||||
"appearanceThemeSystem": "系统",
|
||||
"@appearanceThemeSystem": {
|
||||
"description": "Follow system theme"
|
||||
},
|
||||
"appearanceThemeLight": "Light",
|
||||
"appearanceThemeLight": "浅色",
|
||||
"@appearanceThemeLight": {
|
||||
"description": "Light theme"
|
||||
},
|
||||
"appearanceThemeDark": "Dark",
|
||||
"appearanceThemeDark": "深色",
|
||||
"@appearanceThemeDark": {
|
||||
"description": "Dark theme"
|
||||
},
|
||||
"appearanceDynamicColor": "Dynamic Color",
|
||||
"appearanceDynamicColor": "动态色彩",
|
||||
"@appearanceDynamicColor": {
|
||||
"description": "Material You dynamic colors"
|
||||
},
|
||||
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
|
||||
"appearanceDynamicColorSubtitle": "使用壁纸的颜色",
|
||||
"@appearanceDynamicColorSubtitle": {
|
||||
"description": "Subtitle for dynamic color"
|
||||
},
|
||||
"appearanceHistoryView": "History View",
|
||||
"appearanceHistoryView": "历史记录",
|
||||
"@appearanceHistoryView": {
|
||||
"description": "Layout style for history"
|
||||
},
|
||||
"appearanceHistoryViewList": "List",
|
||||
"appearanceHistoryViewList": "列表",
|
||||
"@appearanceHistoryViewList": {
|
||||
"description": "List layout option"
|
||||
},
|
||||
"appearanceHistoryViewGrid": "Grid",
|
||||
"appearanceHistoryViewGrid": "网格",
|
||||
"@appearanceHistoryViewGrid": {
|
||||
"description": "Grid layout option"
|
||||
},
|
||||
"optionsTitle": "Options",
|
||||
"optionsTitle": "选项",
|
||||
"@optionsTitle": {
|
||||
"description": "Options settings page title"
|
||||
},
|
||||
"optionsPrimaryProvider": "Primary Provider",
|
||||
"optionsPrimaryProvider": "主要提供者",
|
||||
"@optionsPrimaryProvider": {
|
||||
"description": "Main search provider setting"
|
||||
},
|
||||
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
|
||||
"optionsPrimaryProviderSubtitle": "按歌曲名称搜索时使用的服务。",
|
||||
"@optionsPrimaryProviderSubtitle": {
|
||||
"description": "Subtitle for primary provider"
|
||||
},
|
||||
"optionsUsingExtension": "Using extension: {extensionName}",
|
||||
"optionsUsingExtension": "使用扩展:{extensionName}",
|
||||
"@optionsUsingExtension": {
|
||||
"description": "Shows active extension name",
|
||||
"placeholders": {
|
||||
@@ -150,55 +150,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"optionsSwitchBack": "点击 Deezer 或 Spotify 即可从扩展程序切换回来",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"optionsAutoFallback": "自动回退",
|
||||
"@optionsAutoFallback": {
|
||||
"description": "Auto-retry with other services"
|
||||
},
|
||||
"optionsAutoFallbackSubtitle": "Try other services if download fails",
|
||||
"optionsAutoFallbackSubtitle": "如果下载失败,请尝试其他服务",
|
||||
"@optionsAutoFallbackSubtitle": {
|
||||
"description": "Subtitle for auto fallback"
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"optionsUseExtensionProviders": "使用扩展提供商",
|
||||
"@optionsUseExtensionProviders": {
|
||||
"description": "Enable extension download providers"
|
||||
},
|
||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
||||
"optionsUseExtensionProvidersOn": "扩展会被最先尝试",
|
||||
"@optionsUseExtensionProvidersOn": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "仅使用内置提供商",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"optionsEmbedLyrics": "内嵌歌词",
|
||||
"@optionsEmbedLyrics": {
|
||||
"description": "Embed lyrics in audio files"
|
||||
},
|
||||
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
|
||||
"optionsEmbedLyricsSubtitle": "嵌入已同步歌词到 FLAC 文件",
|
||||
"@optionsEmbedLyricsSubtitle": {
|
||||
"description": "Subtitle for embed lyrics"
|
||||
},
|
||||
"optionsMaxQualityCover": "Max Quality Cover",
|
||||
"optionsMaxQualityCover": "最高质量封面",
|
||||
"@optionsMaxQualityCover": {
|
||||
"description": "Download highest quality album art"
|
||||
},
|
||||
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
|
||||
"optionsMaxQualityCoverSubtitle": "下载最高分辨率封面",
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"optionsConcurrentDownloads": "并行下载数",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
},
|
||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
||||
"optionsConcurrentSequential": "按顺序下载(一次一首)",
|
||||
"@optionsConcurrentSequential": {
|
||||
"description": "Download one at a time"
|
||||
},
|
||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
||||
"optionsConcurrentParallel": "同时下载 {count} 首",
|
||||
"@optionsConcurrentParallel": {
|
||||
"description": "Multiple parallel downloads",
|
||||
"placeholders": {
|
||||
@@ -207,67 +207,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
||||
"optionsConcurrentWarning": "并行下载可能会触发速率限制",
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
"optionsExtensionStore": "Extension Store",
|
||||
"optionsExtensionStore": "扩展商店",
|
||||
"@optionsExtensionStore": {
|
||||
"description": "Show/hide store tab"
|
||||
},
|
||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
||||
"optionsExtensionStoreSubtitle": "在导航中显示商店标签",
|
||||
"@optionsExtensionStoreSubtitle": {
|
||||
"description": "Subtitle for extension store toggle"
|
||||
},
|
||||
"optionsCheckUpdates": "Check for Updates",
|
||||
"optionsCheckUpdates": "检查更新",
|
||||
"@optionsCheckUpdates": {
|
||||
"description": "Auto update check toggle"
|
||||
},
|
||||
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
|
||||
"optionsCheckUpdatesSubtitle": "当有新版本可用时通知",
|
||||
"@optionsCheckUpdatesSubtitle": {
|
||||
"description": "Subtitle for update check"
|
||||
},
|
||||
"optionsUpdateChannel": "Update Channel",
|
||||
"optionsUpdateChannel": "更新频道",
|
||||
"@optionsUpdateChannel": {
|
||||
"description": "Stable vs preview releases"
|
||||
},
|
||||
"optionsUpdateChannelStable": "Stable releases only",
|
||||
"optionsUpdateChannelStable": "仅稳定版本",
|
||||
"@optionsUpdateChannelStable": {
|
||||
"description": "Only stable updates"
|
||||
},
|
||||
"optionsUpdateChannelPreview": "Get preview releases",
|
||||
"optionsUpdateChannelPreview": "获取预览版本",
|
||||
"@optionsUpdateChannelPreview": {
|
||||
"description": "Include beta/preview updates"
|
||||
},
|
||||
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
|
||||
"optionsUpdateChannelWarning": "预览版本可能包含错误或者尚未完善的功能",
|
||||
"@optionsUpdateChannelWarning": {
|
||||
"description": "Warning about preview channel"
|
||||
},
|
||||
"optionsClearHistory": "Clear Download History",
|
||||
"optionsClearHistory": "清除下载历史记录",
|
||||
"@optionsClearHistory": {
|
||||
"description": "Delete all download history"
|
||||
},
|
||||
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
|
||||
"optionsClearHistorySubtitle": "从历史记录中清除所有已下载的曲目",
|
||||
"@optionsClearHistorySubtitle": {
|
||||
"description": "Subtitle for clear history"
|
||||
},
|
||||
"optionsDetailedLogging": "Detailed Logging",
|
||||
"optionsDetailedLogging": "详细日志",
|
||||
"@optionsDetailedLogging": {
|
||||
"description": "Enable verbose logs for debugging"
|
||||
},
|
||||
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
|
||||
"optionsDetailedLoggingOn": "正在记录详细日志",
|
||||
"@optionsDetailedLoggingOn": {
|
||||
"description": "Status when logging enabled"
|
||||
},
|
||||
"optionsDetailedLoggingOff": "Enable for bug reports",
|
||||
"optionsDetailedLoggingOff": "为错误报告启用",
|
||||
"@optionsDetailedLoggingOff": {
|
||||
"description": "Status when logging disabled"
|
||||
},
|
||||
"optionsSpotifyCredentials": "Spotify Credentials",
|
||||
"optionsSpotifyCredentials": "Spotify 凭据",
|
||||
"@optionsSpotifyCredentials": {
|
||||
"description": "Spotify API credentials setting"
|
||||
},
|
||||
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
|
||||
"optionsSpotifyCredentialsConfigured": "客户端 ID:{clientId}……",
|
||||
"@optionsSpotifyCredentialsConfigured": {
|
||||
"description": "Shows configured client ID preview",
|
||||
"placeholders": {
|
||||
@@ -276,27 +276,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
|
||||
"optionsSpotifyCredentialsRequired": "必填 - 点击配置",
|
||||
"@optionsSpotifyCredentialsRequired": {
|
||||
"description": "Prompt to set up credentials"
|
||||
},
|
||||
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
|
||||
"optionsSpotifyWarning": "Spotify 需要您自己的 API 凭据。在 developer.spotify.com 免费获取",
|
||||
"@optionsSpotifyWarning": {
|
||||
"description": "Info about Spotify API requirement"
|
||||
},
|
||||
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
|
||||
"optionsSpotifyDeprecationWarning": "Spotify 搜索将在 2026 年 3 月 3 日因 Spotify API 更改而被废弃。请切换到 Deezer。",
|
||||
"@optionsSpotifyDeprecationWarning": {
|
||||
"description": "Warning about Spotify API deprecation"
|
||||
},
|
||||
"extensionsTitle": "Extensions",
|
||||
"extensionsTitle": "扩展",
|
||||
"@extensionsTitle": {
|
||||
"description": "Extensions page title"
|
||||
},
|
||||
"extensionsDisabled": "Disabled",
|
||||
"extensionsDisabled": "禁用",
|
||||
"@extensionsDisabled": {
|
||||
"description": "Extension status - inactive"
|
||||
},
|
||||
"extensionsVersion": "Version {version}",
|
||||
"extensionsVersion": "版本 {version}",
|
||||
"@extensionsVersion": {
|
||||
"description": "Extension version display",
|
||||
"placeholders": {
|
||||
@@ -305,7 +305,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionsAuthor": "by {author}",
|
||||
"extensionsAuthor": "来自 {author}",
|
||||
"@extensionsAuthor": {
|
||||
"description": "Extension author credit",
|
||||
"placeholders": {
|
||||
@@ -314,75 +314,75 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionsUninstall": "Uninstall",
|
||||
"extensionsUninstall": "卸载",
|
||||
"@extensionsUninstall": {
|
||||
"description": "Uninstall extension button"
|
||||
},
|
||||
"storeTitle": "Extension Store",
|
||||
"storeTitle": "扩展商店",
|
||||
"@storeTitle": {
|
||||
"description": "Store screen title"
|
||||
},
|
||||
"storeSearch": "Search extensions...",
|
||||
"storeSearch": "搜索扩展……",
|
||||
"@storeSearch": {
|
||||
"description": "Store search placeholder"
|
||||
},
|
||||
"storeInstall": "Install",
|
||||
"storeInstall": "安装",
|
||||
"@storeInstall": {
|
||||
"description": "Install extension button"
|
||||
},
|
||||
"storeInstalled": "Installed",
|
||||
"storeInstalled": "已安装",
|
||||
"@storeInstalled": {
|
||||
"description": "Already installed badge"
|
||||
},
|
||||
"storeUpdate": "Update",
|
||||
"storeUpdate": "更新",
|
||||
"@storeUpdate": {
|
||||
"description": "Update available button"
|
||||
},
|
||||
"aboutTitle": "About",
|
||||
"aboutTitle": "关于",
|
||||
"@aboutTitle": {
|
||||
"description": "About page title"
|
||||
},
|
||||
"aboutContributors": "Contributors",
|
||||
"aboutContributors": "贡献者",
|
||||
"@aboutContributors": {
|
||||
"description": "Section for contributors"
|
||||
},
|
||||
"aboutMobileDeveloper": "Mobile version developer",
|
||||
"aboutMobileDeveloper": "移动版本开发者",
|
||||
"@aboutMobileDeveloper": {
|
||||
"description": "Role description for mobile dev"
|
||||
},
|
||||
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
|
||||
"aboutOriginalCreator": "原 SpotiLDAC 创建者",
|
||||
"@aboutOriginalCreator": {
|
||||
"description": "Role description for original creator"
|
||||
},
|
||||
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
||||
"aboutLogoArtist": "有才华的艺术家创建了我们美丽的应用图标!",
|
||||
"@aboutLogoArtist": {
|
||||
"description": "Role description for logo artist"
|
||||
},
|
||||
"aboutTranslators": "Translators",
|
||||
"aboutTranslators": "译者",
|
||||
"@aboutTranslators": {
|
||||
"description": "Section for translators"
|
||||
},
|
||||
"aboutSpecialThanks": "Special Thanks",
|
||||
"aboutSpecialThanks": "特别鸣谢",
|
||||
"@aboutSpecialThanks": {
|
||||
"description": "Section for special thanks"
|
||||
},
|
||||
"aboutLinks": "Links",
|
||||
"aboutLinks": "相关链接",
|
||||
"@aboutLinks": {
|
||||
"description": "Section for external links"
|
||||
},
|
||||
"aboutMobileSource": "Mobile source code",
|
||||
"aboutMobileSource": "移动版本源代码",
|
||||
"@aboutMobileSource": {
|
||||
"description": "Link to mobile GitHub repo"
|
||||
},
|
||||
"aboutPCSource": "PC source code",
|
||||
"aboutPCSource": "桌面版本源代码",
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"aboutReportIssue": "报告一个问题",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
},
|
||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
||||
"aboutReportIssueSubtitle": "报告您遇到的任何问题",
|
||||
"@aboutReportIssueSubtitle": {
|
||||
"description": "Subtitle for report issue"
|
||||
},
|
||||
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -603,23 +603,23 @@
|
||||
"@setupNotificationGranted": {
|
||||
"description": "Success message for notification permission"
|
||||
},
|
||||
"setupNotificationEnable": "Enable Notifications",
|
||||
"setupNotificationEnable": "启用通知",
|
||||
"@setupNotificationEnable": {
|
||||
"description": "Button to enable notifications"
|
||||
},
|
||||
"setupFolderChoose": "Choose Download Folder",
|
||||
"setupFolderChoose": "选择下载文件夹",
|
||||
"@setupFolderChoose": {
|
||||
"description": "Button to choose folder"
|
||||
},
|
||||
"setupFolderDescription": "Select a folder where your downloaded music will be saved.",
|
||||
"setupFolderDescription": "选择保存您下载的音乐的文件夹。",
|
||||
"@setupFolderDescription": {
|
||||
"description": "Explanation for folder selection"
|
||||
},
|
||||
"setupSelectFolder": "Select Folder",
|
||||
"setupSelectFolder": "选择文件夹",
|
||||
"@setupSelectFolder": {
|
||||
"description": "Button to select folder"
|
||||
},
|
||||
"setupEnableNotifications": "Enable Notifications",
|
||||
"setupEnableNotifications": "启用通知",
|
||||
"@setupEnableNotifications": {
|
||||
"description": "Button to enable notifications"
|
||||
},
|
||||
@@ -889,14 +889,26 @@
|
||||
"@errorRateLimited": {
|
||||
"description": "Error title - too many requests"
|
||||
},
|
||||
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.",
|
||||
"errorRateLimitedMessage": "请求过多。请等一会再搜索。",
|
||||
"@errorRateLimitedMessage": {
|
||||
"description": "Error message - rate limit explanation"
|
||||
},
|
||||
"errorNoTracksFound": "No tracks found",
|
||||
"errorNoTracksFound": "未找到曲目",
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "By Artist",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
|
||||
+107
-3
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Cannot load {item}: missing extension source",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -1003,6 +1015,14 @@
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "By Artist",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1097,7 +1117,7 @@
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -2383,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2808,6 +2828,90 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
|
||||
+137
-6
@@ -1,16 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -88,15 +92,142 @@ class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
_EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
with WidgetsBindingObserver {
|
||||
ProviderSubscription<bool>? _localLibraryEnabledSub;
|
||||
Timer? _downloadHistoryWarmupTimer;
|
||||
Timer? _libraryCollectionsWarmupTimer;
|
||||
Timer? _localLibraryWarmupTimer;
|
||||
bool _localLibraryWarmupScheduled = false;
|
||||
bool _autoScanTriggeredOnLaunch = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAppServices();
|
||||
_initializeExtensions();
|
||||
ref.read(downloadHistoryProvider);
|
||||
ref.read(localLibraryProvider);
|
||||
ref.read(libraryCollectionsProvider);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_initializeAppServices();
|
||||
_initializeExtensions();
|
||||
_initializeDeferredProviders();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_localLibraryEnabledSub?.close();
|
||||
_downloadHistoryWarmupTimer?.cancel();
|
||||
_libraryCollectionsWarmupTimer?.cancel();
|
||||
_localLibraryWarmupTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_maybeAutoScanLocalLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeDeferredProviders() {
|
||||
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
|
||||
const Duration(milliseconds: 400),
|
||||
() => ref.read(downloadHistoryProvider),
|
||||
);
|
||||
_libraryCollectionsWarmupTimer = _scheduleProviderWarmup(
|
||||
const Duration(milliseconds: 900),
|
||||
() => ref.read(libraryCollectionsProvider),
|
||||
);
|
||||
|
||||
_maybeScheduleLocalLibraryWarmup(
|
||||
ref.read(
|
||||
settingsProvider.select((settings) => settings.localLibraryEnabled),
|
||||
),
|
||||
);
|
||||
|
||||
_localLibraryEnabledSub = ref.listenManual<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);
|
||||
// Trigger auto-scan after initial warmup on first app launch.
|
||||
if (!_autoScanTriggeredOnLaunch) {
|
||||
_autoScanTriggeredOnLaunch = true;
|
||||
// Give the provider a moment to load existing data before scanning.
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _maybeAutoScanLocalLibrary();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks whether an automatic incremental scan should be triggered based on
|
||||
/// the user's auto-scan preference and the time since the last scan.
|
||||
Future<void> _maybeAutoScanLocalLibrary() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.localLibraryEnabled) return;
|
||||
if (settings.localLibraryPath.isEmpty) return;
|
||||
if (settings.localLibraryAutoScan == 'off') return;
|
||||
|
||||
// Don't start a scan if one is already running.
|
||||
final libraryState = ref.read(localLibraryProvider);
|
||||
if (libraryState.isScanning) return;
|
||||
|
||||
// Determine cooldown based on auto-scan mode.
|
||||
final now = DateTime.now();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
||||
|
||||
if (lastScanned != null) {
|
||||
final elapsed = now.difference(lastScanned);
|
||||
|
||||
switch (settings.localLibraryAutoScan) {
|
||||
case 'on_open':
|
||||
// Cooldown of 10 minutes to prevent rapid re-scans.
|
||||
if (elapsed.inMinutes < 10) return;
|
||||
break;
|
||||
case 'daily':
|
||||
if (elapsed.inHours < 24) return;
|
||||
break;
|
||||
case 'weekly':
|
||||
if (elapsed.inDays < 7) return;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed -- start an incremental scan.
|
||||
final iosBookmark = settings.localLibraryBookmark;
|
||||
ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.startScan(
|
||||
settings.localLibraryPath,
|
||||
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initializeAppServices() async {
|
||||
|
||||
@@ -20,6 +20,7 @@ class AppSettings {
|
||||
final String updateChannel;
|
||||
final bool hasSearchedBefore;
|
||||
final String folderOrganization;
|
||||
final bool createPlaylistFolder;
|
||||
final bool useAlbumArtistForFolders;
|
||||
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||
final bool filterContributingArtistsInAlbumArtist;
|
||||
@@ -33,6 +34,7 @@ class AppSettings {
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
final String? searchProvider;
|
||||
final String? homeFeedProvider;
|
||||
final bool separateSingles;
|
||||
final String albumFolderStructure;
|
||||
final bool showExtensionStore;
|
||||
@@ -41,7 +43,7 @@ class AppSettings {
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final int
|
||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
|
||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
|
||||
final int
|
||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
||||
final bool
|
||||
@@ -61,6 +63,8 @@ class AppSettings {
|
||||
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||
final bool
|
||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
final String
|
||||
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
||||
|
||||
final bool
|
||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
@@ -96,6 +100,7 @@ class AppSettings {
|
||||
this.updateChannel = 'stable',
|
||||
this.hasSearchedBefore = false,
|
||||
this.folderOrganization = 'none',
|
||||
this.createPlaylistFolder = false,
|
||||
this.useAlbumArtistForFolders = true,
|
||||
this.usePrimaryArtistOnly = false,
|
||||
this.filterContributingArtistsInAlbumArtist = false,
|
||||
@@ -109,6 +114,7 @@ class AppSettings {
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
this.searchProvider,
|
||||
this.homeFeedProvider,
|
||||
this.separateSingles = false,
|
||||
this.albumFolderStructure = 'artist_album',
|
||||
this.showExtensionStore = true,
|
||||
@@ -126,6 +132,7 @@ class AppSettings {
|
||||
this.localLibraryPath = '',
|
||||
this.localLibraryBookmark = '',
|
||||
this.localLibraryShowDuplicates = true,
|
||||
this.localLibraryAutoScan = 'off',
|
||||
this.hasCompletedTutorial = false,
|
||||
this.lyricsProviders = const [
|
||||
'lrclib',
|
||||
@@ -159,6 +166,7 @@ class AppSettings {
|
||||
String? updateChannel,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
bool? createPlaylistFolder,
|
||||
bool? useAlbumArtistForFolders,
|
||||
bool? usePrimaryArtistOnly,
|
||||
bool? filterContributingArtistsInAlbumArtist,
|
||||
@@ -173,6 +181,8 @@ class AppSettings {
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false,
|
||||
String? homeFeedProvider,
|
||||
bool clearHomeFeedProvider = false,
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
@@ -190,6 +200,7 @@ class AppSettings {
|
||||
String? localLibraryPath,
|
||||
String? localLibraryBookmark,
|
||||
bool? localLibraryShowDuplicates,
|
||||
String? localLibraryAutoScan,
|
||||
bool? hasCompletedTutorial,
|
||||
List<String>? lyricsProviders,
|
||||
bool? lyricsIncludeTranslationNetease,
|
||||
@@ -215,6 +226,7 @@ class AppSettings {
|
||||
updateChannel: updateChannel ?? this.updateChannel,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder,
|
||||
useAlbumArtistForFolders:
|
||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||
@@ -236,6 +248,9 @@ class AppSettings {
|
||||
searchProvider: clearSearchProvider
|
||||
? null
|
||||
: (searchProvider ?? this.searchProvider),
|
||||
homeFeedProvider: clearHomeFeedProvider
|
||||
? null
|
||||
: (homeFeedProvider ?? this.homeFeedProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
@@ -256,6 +271,7 @@ class AppSettings {
|
||||
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
||||
localLibraryShowDuplicates:
|
||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan,
|
||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||
lyricsIncludeTranslationNetease:
|
||||
|
||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false,
|
||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
@@ -38,6 +39,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
homeFeedProvider: json['homeFeedProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
albumFolderStructure:
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
@@ -58,6 +60,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
||||
localLibraryShowDuplicates:
|
||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||
localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
|
||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||
lyricsProviders:
|
||||
(json['lyricsProviders'] as List<dynamic>?)
|
||||
@@ -100,6 +103,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'createPlaylistFolder': instance.createPlaylistFolder,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||
'filterContributingArtistsInAlbumArtist':
|
||||
@@ -114,6 +118,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'homeFeedProvider': instance.homeFeedProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
@@ -131,6 +136,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'localLibraryPath': instance.localLibraryPath,
|
||||
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||
'localLibraryAutoScan': instance.localLibraryAutoScan,
|
||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||
'lyricsProviders': instance.lyricsProviders,
|
||||
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExploreProvider');
|
||||
|
||||
@@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = {
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
final data = {'sections': sections.map((s) => s.toJson()).toList()};
|
||||
await prefs.setString(_cacheKey, jsonEncode(data));
|
||||
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
||||
_log.d('Saved ${sections.length} explore sections to cache');
|
||||
@@ -216,16 +215,16 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
|
||||
// If we have cached content and it's fresh enough, skip network fetch
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||
_log.d('Using cached home feed (fresh enough)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (state.isLoading) {
|
||||
_log.d('Home feed fetch already in progress');
|
||||
return;
|
||||
@@ -237,21 +236,33 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
|
||||
try {
|
||||
final extState = ref.read(extensionProvider);
|
||||
_log.d('Extensions count: ${extState.extensions.length}');
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final preferredId = settings.homeFeedProvider;
|
||||
_log.d(
|
||||
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
|
||||
);
|
||||
|
||||
Extension? targetExt;
|
||||
for (final extension in extState.extensions) {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
continue;
|
||||
}
|
||||
// If user has a preference, use that
|
||||
if (preferredId != null &&
|
||||
preferredId.isNotEmpty &&
|
||||
extension.id == preferredId) {
|
||||
targetExt = extension;
|
||||
break;
|
||||
}
|
||||
// Otherwise take the first available (fallback to spotify-web if found)
|
||||
if (targetExt == null || extension.id == 'spotify-web') {
|
||||
targetExt = extension;
|
||||
if (extension.id == 'spotify-web') {
|
||||
if (preferredId == null && extension.id == 'spotify-web') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (targetExt == null) {
|
||||
_log.w('No extension with homeFeed capability found');
|
||||
state = state.copyWith(
|
||||
@@ -260,7 +271,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_log.i('Fetching home feed from ${targetExt.id}...');
|
||||
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
||||
|
||||
@@ -276,10 +287,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
_log.d('getExtensionHomeFeed success=$success');
|
||||
if (!success) {
|
||||
final error = result['error'] as String? ?? 'Unknown error';
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: error,
|
||||
);
|
||||
state = state.copyWith(isLoading: false, error: error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -291,10 +299,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
.toList();
|
||||
|
||||
_log.i('Fetched ${sections.length} sections');
|
||||
|
||||
|
||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||
final firstItem = sections.first.items.first;
|
||||
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||
_log.d(
|
||||
'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}',
|
||||
);
|
||||
}
|
||||
|
||||
final localGreeting = _getLocalGreeting();
|
||||
@@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
_saveToCache(sections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +332,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||
}
|
||||
|
||||
|
||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||
return ExploreNotifier();
|
||||
});
|
||||
|
||||
@@ -504,6 +504,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
Future<void> _cleanupExtensions({required String reason}) async {
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
_cleanupInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up ($reason)');
|
||||
@@ -519,6 +524,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
state = state.copyWith(
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
extensions: const [],
|
||||
error: null,
|
||||
);
|
||||
_log.i('Extension system disabled on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
||||
await loadExtensions(extensionsDir);
|
||||
@@ -892,7 +908,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllMetadataProviders() {
|
||||
final providers = ['deezer'];
|
||||
final providers = ['deezer', 'qobuz', 'tidal'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasMetadataProvider) {
|
||||
providers.add(ext.id);
|
||||
@@ -911,8 +927,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.contains('deezer')) {
|
||||
result.insert(0, 'deezer');
|
||||
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -9,11 +9,11 @@ import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
|
||||
final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||
final _prefs = SharedPreferences.getInstance();
|
||||
|
||||
@@ -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: 800);
|
||||
static const _progressPollingInterval = Duration(milliseconds: 1200);
|
||||
Timer? _progressTimer;
|
||||
Timer? _progressStreamBootstrapTimer;
|
||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||
@@ -165,10 +165,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
var excludedDownloadedCount = 0;
|
||||
try {
|
||||
final prefs = await prefsFuture;
|
||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||
}
|
||||
lastScannedAt = readLocalLibraryLastScannedAt(prefs);
|
||||
excludedDownloadedCount =
|
||||
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
||||
} catch (e) {
|
||||
@@ -315,7 +312,6 @@ 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;
|
||||
@@ -333,11 +329,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
if (items.isNotEmpty) {
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
}
|
||||
final persistedItems =
|
||||
(await _db.getAll())
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
@@ -345,7 +346,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
items: persistedItems,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
@@ -354,11 +355,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
|
||||
_log.i(
|
||||
'Full scan complete: ${items.length} tracks found, '
|
||||
'Full scan complete: ${persistedItems.length} tracks found, '
|
||||
'$skippedDownloads already in downloads',
|
||||
);
|
||||
await _showScanCompleteNotification(
|
||||
totalTracks: items.length,
|
||||
totalTracks: persistedItems.length,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
@@ -379,18 +380,41 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (_scanCancelRequested) {
|
||||
@@ -399,7 +423,6 @@ 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>?) ??
|
||||
@@ -421,8 +444,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
// Build the incremental merge base from SQLite, not the current
|
||||
// provider state. Startup auto-scan can fire before `state.items` has
|
||||
// finished loading, which would otherwise drop unchanged rows from the
|
||||
// in-memory library until a manual full rescan.
|
||||
final existingJson = await _db.getAll();
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in state.items) item.filePath: item,
|
||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||
item.filePath: item,
|
||||
};
|
||||
final existingDownloadedPaths = <String>[];
|
||||
currentByPath.removeWhere((path, _) {
|
||||
@@ -465,7 +494,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removed items
|
||||
if (deletedPaths.isNotEmpty) {
|
||||
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
||||
for (final path in deletedPaths) {
|
||||
@@ -474,13 +502,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Deleted $deleteCount items from database');
|
||||
}
|
||||
|
||||
final items = currentByPath.values.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
final items =
|
||||
(await _db.getAll())
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
@@ -798,7 +829,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
await clearLocalLibraryLastScannedAt(prefs);
|
||||
await prefs.remove(_excludedDownloadedCountKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
|
||||
@@ -70,7 +70,7 @@ class RecentAccessItem {
|
||||
/// State for recent access history
|
||||
class RecentAccessState {
|
||||
final List<RecentAccessItem> items;
|
||||
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
|
||||
final Set<String> hiddenDownloadIds;
|
||||
final bool isLoaded;
|
||||
|
||||
const RecentAccessState({
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 5;
|
||||
const _currentMigrationVersion = 6;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
|
||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||
|
||||
@@ -37,6 +39,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = AppSettings.fromJson(jsonDecode(json));
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||
await _normalizeYouTubeBitratesIfNeeded();
|
||||
await _normalizeSongLinkRegionIfNeeded();
|
||||
}
|
||||
@@ -50,6 +53,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
||||
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||
});
|
||||
@@ -65,6 +70,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void _syncNetworkCompatibilitySettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
final compatibilityMode = state.networkCompatibilityMode;
|
||||
PlatformBridge.setNetworkCompatibilityOptions(
|
||||
allowHttp: compatibilityMode,
|
||||
@@ -189,6 +196,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||
if (!Platform.isIOS) return;
|
||||
|
||||
final currentDir = state.downloadDirectory.trim();
|
||||
if (currentDir.isEmpty) return;
|
||||
|
||||
final normalizedDir = await validateOrFixIosPath(currentDir);
|
||||
if (normalizedDir == currentDir) return;
|
||||
|
||||
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
|
||||
state = state.copyWith(downloadDirectory: normalizedDir);
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
String _normalizeSongLinkRegion(String region) {
|
||||
final normalized = region.trim().toUpperCase();
|
||||
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
|
||||
@@ -354,6 +375,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setCreatePlaylistFolder(bool enabled) {
|
||||
state = state.copyWith(createPlaylistFolder: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAlbumArtistForFolders(bool enabled) {
|
||||
state = state.copyWith(useAlbumArtistForFolders: enabled);
|
||||
_saveSettings();
|
||||
@@ -385,8 +411,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setMetadataSource(String source) {
|
||||
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
|
||||
state = state.copyWith(metadataSource: normalized);
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@@ -399,6 +424,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHomeFeedProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearHomeFeedProvider: true);
|
||||
} else {
|
||||
state = state.copyWith(homeFeedProvider: provider);
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableLogging(bool enabled) {
|
||||
state = state.copyWith(enableLogging: enabled);
|
||||
_saveSettings();
|
||||
@@ -502,6 +536,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocalLibraryAutoScan(String mode) {
|
||||
state = state.copyWith(localLibraryAutoScan: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setTutorialComplete() {
|
||||
state = state.copyWith(hasCompletedTutorial: true);
|
||||
_saveSettings();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -6,6 +7,7 @@ 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('.');
|
||||
@@ -125,6 +127,7 @@ class StoreState {
|
||||
final String? downloadingId;
|
||||
final String? error;
|
||||
final bool isInitialized;
|
||||
final String registryUrl;
|
||||
|
||||
const StoreState({
|
||||
this.extensions = const [],
|
||||
@@ -135,8 +138,12 @@ 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,
|
||||
@@ -149,6 +156,7 @@ class StoreState {
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
bool? isInitialized,
|
||||
String? registryUrl,
|
||||
}) {
|
||||
return StoreState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
@@ -159,6 +167,7 @@ class StoreState {
|
||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
registryUrl: registryUrl ?? this.registryUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,15 +210,84 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionStore(cacheDir);
|
||||
await refresh();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
_log.i('Extension store initialized');
|
||||
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})');
|
||||
} 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);
|
||||
|
||||
|
||||
+249
-137
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
@@ -18,7 +19,7 @@ class TrackState {
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final String? headerImageUrl; // Artist header image for background
|
||||
final int? monthlyListeners; // Artist monthly listeners
|
||||
final int? monthlyListeners;
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
@@ -30,6 +31,8 @@ class TrackState {
|
||||
searchExtensionId; // Extension ID used for current search results
|
||||
final String?
|
||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
final String?
|
||||
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -52,6 +55,7 @@ class TrackState {
|
||||
this.isShowingRecentAccess = false,
|
||||
this.searchExtensionId,
|
||||
this.selectedSearchFilter,
|
||||
this.searchSource,
|
||||
});
|
||||
|
||||
bool get hasContent =>
|
||||
@@ -83,6 +87,8 @@ class TrackState {
|
||||
String? searchExtensionId,
|
||||
String? selectedSearchFilter,
|
||||
bool clearSelectedSearchFilter = false,
|
||||
String? searchSource,
|
||||
bool clearSearchSource = false,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -108,6 +114,9 @@ class TrackState {
|
||||
selectedSearchFilter: clearSelectedSearchFilter
|
||||
? null
|
||||
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
searchSource: clearSearchSource
|
||||
? null
|
||||
: (searchSource ?? this.searchSource),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -278,7 +287,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
playlistName: type == 'playlist'
|
||||
? result['name'] as String?
|
||||
: null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
coverUrl: normalizeCoverReference(
|
||||
result['cover_url']?.toString(),
|
||||
),
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
@@ -305,10 +316,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl:
|
||||
artistData['image_url'] as String? ??
|
||||
artistData['images'] as String?,
|
||||
headerImageUrl: artistData['header_image'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
(artistData['image_url'] ?? artistData['images'])?.toString(),
|
||||
),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
artistData['header_image']?.toString(),
|
||||
),
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
@@ -349,7 +362,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: id,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -363,7 +376,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: playlistInfo['name'] as String?,
|
||||
coverUrl: playlistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
playlistInfo['images']?.toString(),
|
||||
),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'artist') {
|
||||
@@ -377,7 +392,78 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
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: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_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 = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
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: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -392,68 +478,66 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final type = parsed['type'] as String;
|
||||
final id = parsed['id'] as String;
|
||||
|
||||
_log.i('Tidal URL parsed: type=$type, id=$id');
|
||||
final metadata = await PlatformBridge.getTidalMetadata(type, id);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
|
||||
if (type == 'track') {
|
||||
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');
|
||||
}
|
||||
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: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_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 = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
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: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -505,7 +589,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: parsed['id'] as String?,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -515,11 +599,16 @@ 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 = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: owner?['name'] as String?,
|
||||
coverUrl: owner?['images'] as String?,
|
||||
playlistName: playlistName,
|
||||
coverUrl: coverUrl,
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'artist') {
|
||||
@@ -533,7 +622,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -547,7 +636,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query, {String? filterOverride}) async {
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? filterOverride,
|
||||
String? builtInSearchProvider,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve selected filter during loading
|
||||
@@ -566,52 +659,71 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final searchProvider = settings.searchProvider;
|
||||
final useExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
hasActiveMetadataExtensions &&
|
||||
searchProvider != null &&
|
||||
searchProvider.isNotEmpty;
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
const source = 'deezer';
|
||||
// Determine the effective search provider
|
||||
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||
|
||||
_log.i(
|
||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
|
||||
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Track> extensionTracks = [];
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
if (useExtensions) {
|
||||
// Only use metadata providers for Deezer search (default behavior)
|
||||
if (effectiveProvider == 'deezer') {
|
||||
try {
|
||||
_log.d('Calling extension search API...');
|
||||
final extResults = await PlatformBridge.searchTracksWithExtensions(
|
||||
query,
|
||||
limit: 20,
|
||||
_log.d('Calling metadata provider search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
_log.i(
|
||||
'Metadata providers returned ${metadataTrackResults.length} tracks',
|
||||
);
|
||||
_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.w(
|
||||
'Metadata provider search failed, falling back to Deezer tracks: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
// Call the appropriate search API
|
||||
switch (effectiveProvider) {
|
||||
case 'tidal':
|
||||
_log.d('Calling Tidal search API...');
|
||||
results = await PlatformBridge.searchTidalAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
case 'qobuz':
|
||||
_log.d('Calling Qobuz search API...');
|
||||
results = await PlatformBridge.searchQobuzAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
}
|
||||
_log.i(
|
||||
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
'$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
@@ -622,32 +734,20 @@ 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: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||
'Raw results: ${trackSearchResults.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
|
||||
);
|
||||
|
||||
final tracks = <Track>[];
|
||||
|
||||
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];
|
||||
for (int i = 0; i < trackSearchResults.length; i++) {
|
||||
final t = trackSearchResults[i];
|
||||
try {
|
||||
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}');
|
||||
}
|
||||
tracks.add(_parseSearchTrack(t));
|
||||
} catch (e) {
|
||||
_log.e('Failed to parse track[$i]: $e', e);
|
||||
}
|
||||
@@ -697,7 +797,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
|
||||
_log.i(
|
||||
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
|
||||
'Search complete: ${tracks.length} tracks, ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
|
||||
);
|
||||
|
||||
state = TrackState(
|
||||
@@ -709,6 +809,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
||||
searchSource:
|
||||
effectiveProvider, // Track which service was used for search
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -884,15 +986,17 @@ 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: data['spotify_id'] as String? ?? '',
|
||||
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
|
||||
name: data['name'] as String? ?? '',
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: data['images'] as String?,
|
||||
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -907,26 +1011,32 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
final effectiveSource =
|
||||
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
|
||||
? (nativeId.isNotEmpty ? nativeId : spotifyId)
|
||||
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
id: preferredId,
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
totalTracks: data['total_tracks'] as int?,
|
||||
source:
|
||||
source ??
|
||||
data['source']?.toString() ??
|
||||
data['provider_id']?.toString(),
|
||||
source: effectiveSource,
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
@@ -964,7 +1074,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
providerId: data['provider_id']?.toString(),
|
||||
@@ -975,7 +1087,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return SearchArtist(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
followers: data['followers'] as int? ?? 0,
|
||||
popularity: data['popularity'] as int? ?? 0,
|
||||
);
|
||||
@@ -986,7 +1098,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
releaseDate: data['release_date'] as String?,
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
@@ -998,7 +1110,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
owner: data['owner'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
@@ -81,16 +82,23 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Use extensionId if available, otherwise detect from albumId prefix
|
||||
final providerId =
|
||||
widget.extensionId ??
|
||||
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||
(() {
|
||||
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||
return 'spotify';
|
||||
})();
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.recordAlbumAccess(
|
||||
id: widget.albumId,
|
||||
name: widget.albumName,
|
||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
||||
artistName:
|
||||
widget.artistName ??
|
||||
widget.tracks?.firstOrNull?.albumArtist ??
|
||||
widget.tracks?.firstOrNull?.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
@@ -129,9 +137,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
/// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate).
|
||||
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
|
||||
/// Upgrade cover URL to a higher resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
||||
@@ -175,6 +181,12 @@ 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);
|
||||
@@ -218,7 +230,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
coverUrl: data['images'] as String?,
|
||||
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -272,7 +284,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
) {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||
final artistName =
|
||||
widget.artistName ??
|
||||
(tracks.isNotEmpty
|
||||
? (tracks.first.albumArtist ?? tracks.first.artistName)
|
||||
: null);
|
||||
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||
|
||||
return SliverAppBar(
|
||||
@@ -505,7 +521,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -560,37 +575,82 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
void _downloadAll(BuildContext context) {
|
||||
final tracks = _tracks;
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
|
||||
// Skip already-downloaded tracks
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: widget.albumName,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
.addMultipleToQueue(tracksToQueue, settings.defaultService);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
}
|
||||
}
|
||||
|
||||
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||
final message = skipped > 0
|
||||
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Widget _buildLoveAllButton() {
|
||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||
final tracks = _tracks;
|
||||
@@ -619,7 +679,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
size: 22,
|
||||
color: allLoved ? Colors.redAccent : Colors.white,
|
||||
),
|
||||
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
|
||||
tooltip: allLoved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.tooltipLoveAll,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
@@ -642,7 +704,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
||||
icon: const Icon(Icons.add, size: 22, color: Colors.white),
|
||||
tooltip: 'Add to Playlist',
|
||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
@@ -660,7 +722,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -673,7 +739,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added $addedCount tracks to Loved')),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+190
-18
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionAlbumScreen;
|
||||
@@ -38,12 +39,14 @@ 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,
|
||||
@@ -54,6 +57,7 @@ class _ArtistCache {
|
||||
|
||||
class _CacheEntry {
|
||||
final List<ArtistAlbum> albums;
|
||||
final List<ArtistAlbum>? releases;
|
||||
final List<Track>? topTracks;
|
||||
final String? headerImageUrl;
|
||||
final int? monthlyListeners;
|
||||
@@ -61,6 +65,7 @@ class _CacheEntry {
|
||||
|
||||
_CacheEntry({
|
||||
required this.albums,
|
||||
this.releases,
|
||||
this.topTracks,
|
||||
this.headerImageUrl,
|
||||
this.monthlyListeners,
|
||||
@@ -97,6 +102,7 @@ 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;
|
||||
@@ -104,6 +110,8 @@ 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 = {};
|
||||
@@ -153,7 +161,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId =
|
||||
widget.extensionId ??
|
||||
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||
(() {
|
||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
return 'spotify';
|
||||
})();
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.recordArtistAccess(
|
||||
@@ -169,6 +182,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -185,6 +203,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
} else if (cached != null) {
|
||||
_albums = cached.albums;
|
||||
_releases = cached.releases;
|
||||
_topTracks = cached.topTracks;
|
||||
_headerImageUrl = cached.headerImageUrl;
|
||||
_monthlyListeners = cached.monthlyListeners;
|
||||
@@ -209,6 +228,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
_popularPageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -216,6 +236,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
setState(() => _isLoadingDiscography = true);
|
||||
try {
|
||||
List<ArtistAlbum> albums;
|
||||
List<ArtistAlbum>? releases;
|
||||
List<Track>? topTracks;
|
||||
String? headerImage;
|
||||
int? listeners;
|
||||
@@ -230,6 +251,65 @@ 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);
|
||||
@@ -270,6 +350,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
_ArtistCache.set(
|
||||
widget.artistId,
|
||||
albums: albums,
|
||||
releases: releases,
|
||||
topTracks: topTracks,
|
||||
headerImageUrl: finalHeaderImage,
|
||||
monthlyListeners: finalListeners,
|
||||
@@ -278,6 +359,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = albums;
|
||||
_releases = releases;
|
||||
_topTracks = topTracks;
|
||||
_headerImageUrl = finalHeaderImage;
|
||||
_monthlyListeners = finalListeners;
|
||||
@@ -303,8 +385,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
|
||||
@@ -314,8 +399,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString() ?? album?.id,
|
||||
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl)
|
||||
?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -323,20 +409,28 @@ 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(),
|
||||
source: data['provider_id']?.toString() ?? widget.extensionId,
|
||||
);
|
||||
}
|
||||
|
||||
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'] 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(),
|
||||
name: (data['name'] ?? data['title'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: totalTracks,
|
||||
coverUrl: normalizeCoverReference(
|
||||
(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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -359,6 +453,7 @@ 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;
|
||||
@@ -404,6 +499,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
SliverToBoxAdapter(
|
||||
child: _buildPopularSection(colorScheme),
|
||||
),
|
||||
if (releases.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAlbumSection(
|
||||
'Releases',
|
||||
releases,
|
||||
colorScheme,
|
||||
),
|
||||
),
|
||||
if (albumsOnly.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAlbumSection(
|
||||
@@ -961,6 +1064,24 @@ 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);
|
||||
@@ -1211,7 +1332,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final tracks = _topTracks!.take(5).toList();
|
||||
final tracks = _topTracks!;
|
||||
const tracksPerPage = 5;
|
||||
final pageCount = (tracks.length / tracksPerPage).ceil();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1225,11 +1348,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
...tracks.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final track = entry.value;
|
||||
return _buildPopularTrackItem(index + 1, track, colorScheme);
|
||||
}),
|
||||
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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,6 @@ 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;
|
||||
@@ -363,7 +362,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
if (tracks.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: Center(child: Text('No tracks found for this album')),
|
||||
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -911,8 +910,45 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
BuildContext context,
|
||||
List<DownloadHistoryItem> allTracks,
|
||||
) {
|
||||
String selectedFormat = 'MP3';
|
||||
String selectedBitrate = '320k';
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final sourceFormats = <String>{};
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
final nameToCheck =
|
||||
(item.safFileName != null && item.safFileName!.isNotEmpty)
|
||||
? item.safFileName!.toLowerCase()
|
||||
: item.filePath.toLowerCase();
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -924,7 +960,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final formats = ['MP3', 'Opus'];
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
@@ -961,51 +996,73 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1058,12 +1115,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
: item.filePath.toLowerCase();
|
||||
final ext = nameToCheck.endsWith('.flac')
|
||||
? 'FLAC'
|
||||
: nameToCheck.endsWith('.m4a')
|
||||
? 'M4A'
|
||||
: nameToCheck.endsWith('.mp3')
|
||||
? 'MP3'
|
||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext != null && ext != targetFormat) selected.add(item);
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
@@ -1075,16 +1139,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
isLossless
|
||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
)
|
||||
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1105,7 +1175,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final total = selected.length;
|
||||
final historyDb = HistoryDatabase.instance;
|
||||
final newQuality =
|
||||
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
(targetFormat.toUpperCase() == 'ALAC' ||
|
||||
targetFormat.toUpperCase() == 'FLAC')
|
||||
? '${targetFormat.toUpperCase()} Lossless'
|
||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
final settings = ref.read(settingsProvider);
|
||||
final shouldEmbedLyrics =
|
||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||
@@ -1133,12 +1206,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||
if (result['error'] == null) {
|
||||
result.forEach((key, value) {
|
||||
if (key == 'error' || value == null) return;
|
||||
final v = value.toString().trim();
|
||||
if (v.isEmpty) return;
|
||||
metadata[key.toUpperCase()] = v;
|
||||
});
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
}
|
||||
} catch (_) {}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
@@ -1208,13 +1276,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final newFileName = '$baseName$newExt';
|
||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
|
||||
+139
-18
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -22,6 +23,7 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -81,6 +83,37 @@ 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,
|
||||
@@ -164,9 +197,7 @@ _RecentAccessView _buildRecentAccessViewData(
|
||||
}
|
||||
|
||||
final recentAccessViewProvider = Provider<_RecentAccessView>((ref) {
|
||||
final historyItems = ref.watch(
|
||||
downloadHistoryProvider.select((s) => s.items),
|
||||
);
|
||||
final historyItems = ref.watch(_homeHistoryPreviewProvider);
|
||||
final recentAccessItems = ref.watch(
|
||||
recentAccessProvider.select((s) => s.items),
|
||||
);
|
||||
@@ -459,6 +490,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||
|
||||
// Built-in providers (tidal, qobuz) also support live search
|
||||
if (_builtInSearchProviders.contains(searchProvider)) return true;
|
||||
|
||||
final extension = extState.extensions
|
||||
.where((e) => e.id == searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
@@ -516,6 +550,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in search providers that are not extensions
|
||||
static const _builtInSearchProviders = {'tidal', 'qobuz'};
|
||||
|
||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
@@ -528,9 +565,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
|
||||
final isBuiltInProvider =
|
||||
searchProvider != null &&
|
||||
_builtInSearchProviders.contains(searchProvider);
|
||||
|
||||
final isExtensionEnabled =
|
||||
searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isBuiltInProvider &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
@@ -541,10 +583,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.customSearch(searchProvider, query, options: options);
|
||||
} else if (isBuiltInProvider) {
|
||||
// Use built-in Tidal or Qobuz search
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.search(
|
||||
query,
|
||||
filterOverride: selectedFilter,
|
||||
builtInSearchProvider: searchProvider,
|
||||
);
|
||||
} else {
|
||||
if (searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isExtensionEnabled) {
|
||||
!isExtensionEnabled &&
|
||||
!isBuiltInProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref
|
||||
@@ -688,6 +740,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: trackState.searchSource,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
@@ -816,7 +869,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
const SizedBox(height: 12),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Skip already downloaded songs'),
|
||||
title: Text(l10n.homeSkipAlreadyDownloaded),
|
||||
value: skipDownloaded,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
@@ -987,9 +1040,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final screenHeight = mediaQuery.size.height;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final historyItems = ref.watch(
|
||||
downloadHistoryProvider.select((s) => s.items),
|
||||
);
|
||||
final historyItems = ref.watch(_homeHistoryPreviewProvider);
|
||||
|
||||
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
|
||||
final showRecentAccess =
|
||||
@@ -1750,7 +1801,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.album, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Go to Album'),
|
||||
title: Text(context.l10n.homeGoToAlbum),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_navigateToTrackAlbum(item);
|
||||
@@ -1822,9 +1873,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Album info not available')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.homeAlbumInfoUnavailable)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2742,6 +2793,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
// Check built-in providers first
|
||||
if (searchProvider == 'tidal') {
|
||||
return 'Search with Tidal...';
|
||||
}
|
||||
if (searchProvider == 'qobuz') {
|
||||
return 'Search with Qobuz...';
|
||||
}
|
||||
|
||||
final ext = extState.extensions
|
||||
.where((e) => e.id == searchProvider)
|
||||
.firstOrNull;
|
||||
@@ -2976,6 +3035,11 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
// Check if current provider is a built-in provider (tidal/qobuz)
|
||||
const builtInProviders = {'tidal', 'qobuz'};
|
||||
final isBuiltInProvider =
|
||||
currentProvider != null && builtInProviders.contains(currentProvider);
|
||||
|
||||
IconData displayIcon = Icons.search;
|
||||
String? iconPath;
|
||||
if (currentExt != null) {
|
||||
@@ -2983,10 +3047,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
if (currentExt.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||
}
|
||||
}
|
||||
|
||||
if (searchProviders.isEmpty) {
|
||||
return const Icon(Icons.search);
|
||||
} else if (isBuiltInProvider) {
|
||||
displayIcon = Icons.music_note;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -3053,6 +3115,62 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Built-in Tidal search option
|
||||
PopupMenuItem<String>(
|
||||
value: 'tidal',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == 'tidal'
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tidal',
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == 'tidal'
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == 'tidal')
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Built-in Qobuz search option
|
||||
PopupMenuItem<String>(
|
||||
value: 'qobuz',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == 'qobuz'
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Qobuz',
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == 'qobuz'
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == 'qobuz')
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||
...searchProviders.map(
|
||||
(ext) => PopupMenuItem<String>(
|
||||
@@ -3881,6 +3999,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
||||
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
@@ -4188,7 +4307,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
artists: (data['artists'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: data['cover_url']?.toString(),
|
||||
coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
|
||||
albumType: (data['album_type'] ?? 'album').toString(),
|
||||
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
@@ -4213,7 +4332,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
|
||||
@@ -39,6 +39,7 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedKeys = {};
|
||||
UserPlaylistCollection? playlist;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -243,7 +244,6 @@ class _LibraryTracksFolderScreenState
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
ref.watch(localLibraryProvider.select((s) => s.items));
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
final UserPlaylistCollection? playlist;
|
||||
final List<CollectionTrackEntry> entries;
|
||||
|
||||
switch (widget.mode) {
|
||||
@@ -850,8 +850,8 @@ class _LibraryTracksFolderScreenState
|
||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: const Text('Download All'),
|
||||
content: Text('Download ${tracks.length} tracks?'),
|
||||
title: Text(context.l10n.dialogDownloadAllTitle),
|
||||
content: Text(context.l10n.dialogDownloadAllMessage(tracks.length)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
@@ -862,7 +862,7 @@ class _LibraryTracksFolderScreenState
|
||||
Navigator.pop(dialogContext);
|
||||
_downloadAll(tracks);
|
||||
},
|
||||
child: const Text('Download'),
|
||||
child: Text(context.l10n.dialogDownload),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -872,11 +872,54 @@ class _LibraryTracksFolderScreenState
|
||||
|
||||
void _downloadAll(List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final playlistName = widget.mode == LibraryTracksFolderMode.playlist
|
||||
? playlist?.name ?? context.l10n.collectionPlaylist
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
var skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: switch (widget.mode) {
|
||||
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
||||
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
|
||||
@@ -885,12 +928,24 @@ class _LibraryTracksFolderScreenState
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
playlistName: playlistName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(
|
||||
tracksToQueue.length,
|
||||
skippedCount,
|
||||
)
|
||||
: context.l10n.snackbarAddedTracksToQueue(
|
||||
tracksToQueue.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -899,10 +954,21 @@ class _LibraryTracksFolderScreenState
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
settings.defaultService,
|
||||
playlistName: playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
content: Text(
|
||||
skippedCount > 0
|
||||
? context.l10n.discographySkippedDownloaded(
|
||||
tracksToQueue.length,
|
||||
skippedCount,
|
||||
)
|
||||
: context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
@@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
void _showCueVirtualTrackSnackBar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(cueVirtualTrackRequiresSplitMessage),
|
||||
),
|
||||
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
|
||||
);
|
||||
}
|
||||
|
||||
late List<int> _sortedDiscNumbersCache;
|
||||
late bool _hasMultipleDiscsCache;
|
||||
String? _commonQualityCache;
|
||||
@@ -247,7 +250,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
if (tracks.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: const Center(child: Text('No tracks found for this album')),
|
||||
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -623,11 +626,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
slivers.add(
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) =>
|
||||
_buildTrackItem(context, colorScheme, discTracks[index]),
|
||||
childCount: discTracks.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = discTracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
);
|
||||
}, childCount: discTracks.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -815,6 +820,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final format = item.format?.toLowerCase();
|
||||
final lowerPath = item.filePath.toLowerCase();
|
||||
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
||||
final isM4A =
|
||||
format == 'm4a' ||
|
||||
format == 'aac' ||
|
||||
lowerPath.endsWith('.m4a') ||
|
||||
lowerPath.endsWith('.aac');
|
||||
final isOpus =
|
||||
format == 'opus' ||
|
||||
format == 'ogg' ||
|
||||
@@ -828,6 +838,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
@@ -897,6 +913,128 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<LocalLibraryItem> _selectedFlacEligibleItems(
|
||||
List<LocalLibraryItem> allTracks,
|
||||
) {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
return _selectedIds
|
||||
.map((id) => tracksById[id])
|
||||
.whereType<LocalLibraryItem>()
|
||||
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
|
||||
final selected = _selectedFlacEligibleItems(allTracks);
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.queueFlacAction),
|
||||
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.queueFlacAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any(
|
||||
(ext) => ext.enabled && ext.hasMetadataProvider,
|
||||
);
|
||||
final targetService = LocalTrackRedownloadService.preferredFlacService(
|
||||
settings,
|
||||
);
|
||||
final targetQuality =
|
||||
LocalTrackRedownloadService.preferredFlacQualityForService(
|
||||
targetService,
|
||||
);
|
||||
|
||||
final matchedTracks = <Track>[];
|
||||
var skippedCount = 0;
|
||||
final total = selected.length;
|
||||
|
||||
for (var i = 0; i < total; i++) {
|
||||
if (!mounted) break;
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
||||
duration: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
|
||||
selected[i],
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
if (resolution.canQueue && resolution.match != null) {
|
||||
matchedTracks.add(resolution.match!);
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (_) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
if (matchedTracks.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
matchedTracks,
|
||||
targetService,
|
||||
qualityOverride: targetQuality,
|
||||
);
|
||||
|
||||
final summary = skippedCount == 0
|
||||
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
|
||||
: context.l10n.queueFlacQueuedWithSkipped(
|
||||
matchedTracks.length,
|
||||
skippedCount,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(summary)));
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
_isSelectionMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
@@ -1005,8 +1143,57 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
BuildContext context,
|
||||
List<LocalLibraryItem> allTracks,
|
||||
) {
|
||||
String selectedFormat = 'MP3';
|
||||
String selectedBitrate = '320k';
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final sourceFormats = <String>{};
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
String? ext;
|
||||
if (item.format != null && item.format!.isNotEmpty) {
|
||||
final fmt = item.format!.toLowerCase();
|
||||
if (fmt == 'flac') {
|
||||
ext = 'FLAC';
|
||||
} else if (fmt == 'm4a') {
|
||||
ext = 'M4A';
|
||||
} else if (fmt == 'mp3') {
|
||||
ext = 'MP3';
|
||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||
ext = 'Opus';
|
||||
}
|
||||
}
|
||||
if (ext == null) {
|
||||
final lower = item.filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) {
|
||||
ext = 'FLAC';
|
||||
} else if (lower.endsWith('.m4a')) {
|
||||
ext = 'M4A';
|
||||
} else if (lower.endsWith('.mp3')) {
|
||||
ext = 'MP3';
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
ext = 'Opus';
|
||||
}
|
||||
}
|
||||
if (ext != null) sourceFormats.add(ext);
|
||||
}
|
||||
|
||||
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
|
||||
return sourceFormats.any((src) {
|
||||
if (src == target) return false;
|
||||
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
|
||||
final isLosslessSource = src == 'FLAC' || src == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) return false;
|
||||
return true;
|
||||
});
|
||||
}).toList();
|
||||
|
||||
if (formats.isEmpty) return;
|
||||
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -1018,7 +1205,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final formats = ['MP3', 'Opus'];
|
||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||
|
||||
return SafeArea(
|
||||
@@ -1055,51 +1241,73 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
children: formats.map((format) {
|
||||
final isSelected = format == selectedFormat;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
label: Text(format),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.trackConvertBitrate,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: bitrates.map((br) {
|
||||
final isSelected = br == selectedBitrate;
|
||||
return ChoiceChip(
|
||||
label: Text(br),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
setSheetState(() => selectedBitrate = br);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -1152,6 +1360,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final fmt = item.format!.toLowerCase();
|
||||
if (fmt == 'flac') {
|
||||
currentFormat = 'FLAC';
|
||||
} else if (fmt == 'm4a') {
|
||||
currentFormat = 'M4A';
|
||||
} else if (fmt == 'mp3') {
|
||||
currentFormat = 'MP3';
|
||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||
@@ -1163,15 +1373,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final lower = item.filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac')) {
|
||||
currentFormat = 'FLAC';
|
||||
} else if (lower.endsWith('.m4a')) {
|
||||
currentFormat = 'M4A';
|
||||
} else if (lower.endsWith('.mp3')) {
|
||||
currentFormat = 'MP3';
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
currentFormat = 'Opus';
|
||||
}
|
||||
}
|
||||
if (currentFormat != null && currentFormat != targetFormat) {
|
||||
selected.add(item);
|
||||
}
|
||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource =
|
||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
selected.add(item);
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
@@ -1183,16 +1399,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
isLossless
|
||||
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
)
|
||||
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||
selected.length,
|
||||
targetFormat,
|
||||
bitrate,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -1239,12 +1461,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||
if (result['error'] == null) {
|
||||
result.forEach((key, value) {
|
||||
if (key == 'error' || value == null) return;
|
||||
final v = value.toString().trim();
|
||||
if (v.isEmpty) return;
|
||||
metadata[key.toUpperCase()] = v;
|
||||
});
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
}
|
||||
} catch (_) {}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
@@ -1357,13 +1574,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final baseName = dotIdx > 0
|
||||
? oldFileName.substring(0, dotIdx)
|
||||
: oldFileName;
|
||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
||||
? '.opus'
|
||||
: '.mp3';
|
||||
String newExt;
|
||||
String mimeType;
|
||||
switch (targetFormat.toLowerCase()) {
|
||||
case 'opus':
|
||||
newExt = '.opus';
|
||||
mimeType = 'audio/opus';
|
||||
break;
|
||||
case 'alac':
|
||||
newExt = '.m4a';
|
||||
mimeType = 'audio/mp4';
|
||||
break;
|
||||
case 'flac':
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
}
|
||||
final newFileName = '$baseName$newExt';
|
||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||
? 'audio/opus'
|
||||
: 'audio/mpeg';
|
||||
|
||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||
treeUri: treeUri,
|
||||
@@ -1434,6 +1665,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
double bottomPadding,
|
||||
) {
|
||||
final selectedCount = _selectedIds.length;
|
||||
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
|
||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
@@ -1525,6 +1757,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (flacEligibleCount > 0) ...[
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||
onPressed: () => _queueSelectedAsFlac(tracks),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: _LocalAlbumSelectionActionButton(
|
||||
icon: Icons.auto_fix_high_outlined,
|
||||
|
||||
+48
-27
@@ -33,7 +33,7 @@ class MainShell extends ConsumerStatefulWidget {
|
||||
|
||||
class _MainShellState extends ConsumerState<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
late PageController _pageController;
|
||||
late final PageController _pageController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress;
|
||||
@@ -83,7 +83,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
_log.d('Waiting for extensions to initialize before handling URL...');
|
||||
// Wait up to 5 seconds for extensions to initialize
|
||||
for (int i = 0; i < 50; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted) return;
|
||||
@@ -113,17 +112,18 @@ 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,12 +158,10 @@ 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);
|
||||
@@ -181,25 +179,20 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
size: 32,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
title: const Text('Storage Update Required'),
|
||||
content: const Column(
|
||||
title: Text(context.l10n.safMigrationTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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.',
|
||||
),
|
||||
Text(context.l10n.safMigrationMessage1),
|
||||
const SizedBox(height: 12),
|
||||
Text(context.l10n.safMigrationMessage2),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Later'),
|
||||
child: Text(context.l10n.updateLater),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
@@ -219,15 +212,13 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download folder updated to SAF mode'),
|
||||
),
|
||||
SnackBar(content: Text(context.l10n.safMigrationSuccess)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Select Folder'),
|
||||
child: Text(context.l10n.setupSelectFolder),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -260,6 +251,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
if (_currentIndex != index) {
|
||||
final shouldResetHome = index == 0;
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
@@ -269,6 +261,10 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (shouldResetHome) {
|
||||
_resetHomeToMain();
|
||||
}
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
@@ -508,11 +504,15 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
return true;
|
||||
},
|
||||
child: Scaffold(
|
||||
body: PageView(
|
||||
body: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: tabs.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: tabs,
|
||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||
key: ValueKey('page-$index'),
|
||||
child: tabs[index],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
@@ -573,6 +573,27 @@ 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});
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
@@ -39,8 +40,12 @@ 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() {
|
||||
@@ -65,18 +70,25 @@ 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);
|
||||
}
|
||||
|
||||
final 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>?;
|
||||
|
||||
// Go backend returns 'track_list' not 'tracks'
|
||||
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList
|
||||
@@ -85,6 +97,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
|
||||
setState(() {
|
||||
_fetchedTracks = tracks;
|
||||
_resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name'])
|
||||
?.toString();
|
||||
_resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images'])
|
||||
?.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -113,7 +129,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -184,7 +202,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||
child: Text(
|
||||
widget.playlistName,
|
||||
_playlistName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -206,10 +224,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
if (_coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl:
|
||||
_highResCoverUrl(widget.coverUrl) ?? widget.coverUrl!,
|
||||
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) =>
|
||||
@@ -256,7 +273,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.playlistName,
|
||||
_playlistName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
@@ -336,7 +353,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -416,7 +432,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, service, qualityOverride: quality, playlistName: widget.playlistName);
|
||||
.addToQueue(
|
||||
track,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||
@@ -427,7 +448,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, settings.defaultService, playlistName: widget.playlistName);
|
||||
.addToQueue(
|
||||
track,
|
||||
settings.defaultService,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
@@ -482,7 +507,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
size: 22,
|
||||
color: allLoved ? Colors.redAccent : Colors.white,
|
||||
),
|
||||
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
|
||||
tooltip: allLoved
|
||||
? context.l10n.trackOptionRemoveFromLoved
|
||||
: context.l10n.tooltipLoveAll,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
@@ -505,10 +532,15 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
Widget _buildAddToPlaylistButton(BuildContext context) {
|
||||
return _buildCircleButton(
|
||||
icon: Icons.playlist_add,
|
||||
tooltip: 'Add to Playlist',
|
||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||
onPressed: _tracks.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
|
||||
: () => showAddTracksToPlaylistSheet(
|
||||
context,
|
||||
ref,
|
||||
_tracks,
|
||||
playlistNamePrefill: widget.playlistName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -520,8 +552,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
final colorScheme = Theme.of(dialogContext).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
title: const Text('Download All'),
|
||||
content: Text('Download ${_tracks.length} tracks?'),
|
||||
title: Text(context.l10n.dialogDownloadAllTitle),
|
||||
content: Text(context.l10n.dialogDownloadAllMessage(_tracks.length)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
@@ -532,7 +564,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
Navigator.pop(dialogContext);
|
||||
_downloadAll(context);
|
||||
},
|
||||
child: const Text('Download'),
|
||||
child: Text(context.l10n.dialogDownload),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -552,7 +584,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -565,7 +601,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added $addedCount tracks to Loved')),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -577,36 +615,86 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
|
||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
// Skip already-downloaded tracks
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
? ref.read(localLibraryProvider)
|
||||
: null;
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
|
||||
for (final track in tracks) {
|
||||
final isInHistory =
|
||||
historyState.isDownloaded(track.id) ||
|
||||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
) ??
|
||||
false;
|
||||
|
||||
if (isInHistory || isInLocal) {
|
||||
skippedCount++;
|
||||
} else {
|
||||
tracksToQueue.add(track);
|
||||
}
|
||||
}
|
||||
|
||||
if (tracksToQueue.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
trackName: '${tracks.length} tracks',
|
||||
artistName: widget.playlistName,
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: _playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: widget.playlistName);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
||||
),
|
||||
),
|
||||
);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracks, settings.defaultService, playlistName: widget.playlistName);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
||||
),
|
||||
);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
settings.defaultService,
|
||||
playlistName: _playlistName,
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
}
|
||||
}
|
||||
|
||||
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||
final message = skipped > 0
|
||||
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
|
||||
+778
-394
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -52,7 +53,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
.addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -234,7 +234,7 @@ class AboutPage extends StatelessWidget {
|
||||
icon: Icons.info_outline,
|
||||
title: context.l10n.aboutVersion,
|
||||
subtitle:
|
||||
'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
||||
'v${AppInfo.displayVersion} (build ${AppInfo.buildNumber})',
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
@@ -341,7 +341,7 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'v${AppInfo.version}',
|
||||
'v${AppInfo.displayVersion}',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -511,6 +511,30 @@ class _TranslatorsSection extends StatelessWidget {
|
||||
language: 'Japanese',
|
||||
flag: '🇯🇵',
|
||||
),
|
||||
_Translator(
|
||||
name: 'unkn0wn',
|
||||
crowdinUsername: 'rdclvi',
|
||||
language: 'Indonesian',
|
||||
flag: '🇮🇩',
|
||||
),
|
||||
_Translator(
|
||||
name: 'lunching1272',
|
||||
crowdinUsername: 'lunching1272',
|
||||
language: 'Chinese Simplified',
|
||||
flag: '🇨🇳',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Сергей Ильченко',
|
||||
crowdinUsername: 'Sega_Mostky',
|
||||
language: 'Russian',
|
||||
flag: '🇷🇺',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Girl-lass',
|
||||
crowdinUsername: 'Girl-lass',
|
||||
language: 'Chinese Simplified',
|
||||
flag: '🇨🇳',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Kaan',
|
||||
crowdinUsername: 'glai',
|
||||
|
||||
@@ -56,7 +56,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _busyAction = null);
|
||||
@@ -394,7 +394,7 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
tooltip: context.l10n.cacheRefresh,
|
||||
onPressed: _isBusy ? null : _refreshOverview,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
|
||||
@@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = <String>['a fan'];
|
||||
const donorNames = <String>[
|
||||
'McNuggets Jimmy',
|
||||
'zcc09',
|
||||
'micahRichie',
|
||||
'a fan',
|
||||
'CJBGR',
|
||||
];
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
@@ -479,32 +485,78 @@ int _cr(String v) {
|
||||
return r;
|
||||
}
|
||||
|
||||
// Highlighted supporters (hashes of names): none for now.
|
||||
const _cv = <int>{};
|
||||
// Highlighted supporters (hashes of names).
|
||||
const _cv = <int>{1211573191, 1003219236};
|
||||
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
// Diamond tier supporters ($50+ donors).
|
||||
const _dv = <int>{560908930};
|
||||
|
||||
enum _SupporterTier { normal, gold, diamond }
|
||||
|
||||
_SupporterTier _tierOf(String name) {
|
||||
final h = _cr(name);
|
||||
if (_dv.contains(h)) return _SupporterTier.diamond;
|
||||
if (_cv.contains(h)) return _SupporterTier.gold;
|
||||
return _SupporterTier.normal;
|
||||
}
|
||||
|
||||
class _SupporterChip extends StatefulWidget {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _SupporterChip({required this.name, required this.colorScheme});
|
||||
|
||||
@override
|
||||
State<_SupporterChip> createState() => _SupporterChipState();
|
||||
}
|
||||
|
||||
class _SupporterChipState extends State<_SupporterChip>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _SupporterTier _tier;
|
||||
AnimationController? _shimmerController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tier = _tierOf(widget.name);
|
||||
if (_tier == _SupporterTier.diamond) {
|
||||
_shimmerController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2400),
|
||||
)..repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shimmerController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = _cv.contains(_cr(name));
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
if (_tier == _SupporterTier.diamond) {
|
||||
return _buildDiamondChip(isDark);
|
||||
}
|
||||
|
||||
final isGold = _tier == _SupporterTier.gold;
|
||||
const goldChipColor = Color(0xFFFFF8DC);
|
||||
const goldAccentColor = Color(0xFFB8860B);
|
||||
const goldDarkChipColor = Color(0xFF3A3000);
|
||||
|
||||
final chipColor = e ? goldChipColor : colorScheme.secondaryContainer;
|
||||
final accentColor = e ? goldAccentColor : colorScheme.primary;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor;
|
||||
final chipColor = isGold
|
||||
? goldChipColor
|
||||
: widget.colorScheme.secondaryContainer;
|
||||
final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary;
|
||||
final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor;
|
||||
|
||||
return Material(
|
||||
color: effectiveChipColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: e
|
||||
decoration: isGold
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
@@ -520,10 +572,12 @@ class _SupporterChip extends StatelessWidget {
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||
child: e
|
||||
child: isGold
|
||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
||||
: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
widget.name.isNotEmpty
|
||||
? widget.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -533,10 +587,12 @@ class _SupporterChip extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
name,
|
||||
widget.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: e ? accentColor : colorScheme.onSecondaryContainer,
|
||||
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isGold
|
||||
? accentColor
|
||||
: widget.colorScheme.onSecondaryContainer,
|
||||
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -544,6 +600,92 @@ class _SupporterChip extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiamondChip(bool isDark) {
|
||||
const diamondLight = Color(0xFFE8F4FD);
|
||||
const diamondDark = Color(0xFF0D2B3E);
|
||||
const diamondAccent = Color(0xFF4FC3F7);
|
||||
const diamondHighlight = Color(0xFFB3E5FC);
|
||||
|
||||
final chipBg = isDark ? diamondDark : diamondLight;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _shimmerController!,
|
||||
builder: (context, child) {
|
||||
final t = _shimmerController!.value;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment(-2.0 + 4.0 * t, 0.0),
|
||||
end: Alignment(-1.0 + 4.0 * t, 0.0),
|
||||
colors: [
|
||||
chipBg,
|
||||
isDark
|
||||
? diamondAccent.withValues(alpha: 0.18)
|
||||
: diamondHighlight.withValues(alpha: 0.7),
|
||||
chipBg,
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
border: Border.all(
|
||||
color: diamondAccent.withValues(
|
||||
alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()),
|
||||
),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: diamondAccent.withValues(
|
||||
alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()),
|
||||
),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
diamondAccent.withValues(alpha: 0.3),
|
||||
diamondAccent.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.diamond_rounded,
|
||||
size: 12,
|
||||
color: diamondAccent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: isDark ? diamondHighlight : diamondAccent,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeLine extends StatelessWidget {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user