mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
Compare commits
227 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b | |||
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 | |||
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| b2873378fc | |||
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| bffeb55a7a | |||
| 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 }}
|
VERSION=${{ needs.get-version.outputs.version }}
|
||||||
REPO_OWNER="${{ github.repository_owner }}"
|
REPO_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
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
|
# Start with git-cliff changelog, but replace its compare footer with a
|
||||||
cp /tmp/changelog.txt /tmp/release_body.txt
|
# 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
|
# Append download section
|
||||||
cat >> /tmp/release_body.txt << FOOTER
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
@@ -384,6 +393,63 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
notify-telegram:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [get-version, create-release]
|
needs: [get-version, create-release]
|
||||||
@@ -424,7 +490,10 @@ jobs:
|
|||||||
else
|
else
|
||||||
# Convert Markdown to Telegram HTML
|
# Convert Markdown to Telegram HTML
|
||||||
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||||
|
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||||
sed '/^\*\*Full Changelog\*\*/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/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||||
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||||
sed 's/&/\&/g' | \
|
sed 's/&/\&/g' | \
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ AGENTS.md
|
|||||||
|
|
||||||
# Temp/misc
|
# Temp/misc
|
||||||
nul
|
nul
|
||||||
|
network_requests.txt
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
@@ -76,3 +77,6 @@ flutter_*.log
|
|||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
.claude/settings.local.json
|
.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
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
- 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
|
- 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
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
- Includes heuristic detection of lyrics stored in Comment fields
|
- 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`
|
- 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)
|
- 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 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 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)
|
- 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
|
- 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
|
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
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||||
```bash
|
```bash
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
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
|
```bash
|
||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,6 +14,17 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
|
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||||
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
|
[](https://t.me/spotiflac)
|
||||||
|
[](https://t.me/spotiflac_chat)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -23,68 +34,141 @@
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
---
|
||||||
|
|
||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
|
||||||
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
|
||||||
[](https://t.me/spotiflac_chat)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||||
|
|
||||||
### Installing Extensions
|
### Installing Extensions
|
||||||
1. Go to **Store** tab in the app
|
|
||||||
2. Browse and install extensions with one tap
|
1. Open the **Store** tab in the app
|
||||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||||
4. Configure extension settings if needed
|
3. Browse and install extensions with one tap
|
||||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||||
|
5. Configure extension settings if needed
|
||||||
|
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||||
|
|
||||||
### Developing Extensions
|
### Developing Extensions
|
||||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
|
||||||
|
|
||||||
## Other project
|
> [!NOTE]
|
||||||
|
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||||
|
|
||||||
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
|
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
<details>
|
||||||
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.
|
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Why are some tracks downloading in lower quality?**
|
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.
|
||||||
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
|
|
||||||
|
|
||||||
**Q: Can I download playlists?**
|
</details>
|
||||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
|
||||||
|
|
||||||
**Q: Why do I need to grant storage permission?**
|
<details>
|
||||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Is this app safe?**
|
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
|
||||||
|
|
||||||
**Q: Why is download not working in my country?**
|
</details>
|
||||||
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.
|
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
### Want to support SpotiFLAC-Mobile?
|
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||||
|
- **Tidal** up to 24-bit/192kHz
|
||||||
|
- **Qobuz** up to 24-bit/192kHz
|
||||||
|
- **Deezer** up to 16-bit/44.1kHz
|
||||||
|
|
||||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
</details>
|
||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
<details>
|
||||||
|
<summary><b>Can I download playlists?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Is this app safe?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||||
|
```
|
||||||
|
|
||||||
|
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> If SpotiFLAC is useful to you, consider supporting development:
|
||||||
|
>
|
||||||
|
> [](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to everyone who has 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 helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||||
|
|
||||||
|
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Credits
|
## 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)
|
| | | | | |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [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) | |
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
|
||||||
|
|||||||
@@ -9,6 +9,19 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- .dart_tool/**
|
||||||
|
- lib/**/*.g.dart
|
||||||
|
- lib/l10n/*.dart
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
|
strict-raw-types: true
|
||||||
|
plugins:
|
||||||
|
- custom_lint
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -23,6 +36,13 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
avoid_dynamic_calls: true
|
||||||
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
|
||||||
|
custom_lint:
|
||||||
|
rules:
|
||||||
|
- avoid_public_notifier_properties
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class DownloadService : Service() {
|
|||||||
updateNotification(progress, total)
|
updateNotification(progress, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return START_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -115,10 +115,8 @@ class DownloadService : Service() {
|
|||||||
* We must call stopSelf() within a few seconds to avoid a crash.
|
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||||
*/
|
*/
|
||||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
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.")
|
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||||
|
|
||||||
// Gracefully stop the service
|
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +137,13 @@ class DownloadService : Service() {
|
|||||||
|
|
||||||
private fun startForegroundService() {
|
private fun startForegroundService() {
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
|
||||||
// Acquire wake lock to prevent CPU sleep
|
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
wakeLock = powerManager.newWakeLock(
|
wakeLock = powerManager.newWakeLock(
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
WAKELOCK_TAG
|
WAKELOCK_TAG
|
||||||
).apply {
|
).apply {
|
||||||
acquire(60 * 60 * 1000L) // 1 hour max
|
acquire(60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = buildNotification(0, 0)
|
val notification = buildNotification(0, 0)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.9.0",
|
||||||
|
"versionDate": "2026-03-25",
|
||||||
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-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": 34477323
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-3
@@ -22,7 +22,7 @@ body = """
|
|||||||
{% if commit.github.pr_number %} \
|
{% if commit.github.pr_number %} \
|
||||||
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||||
{% endif %}\
|
{% 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 %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
@@ -58,8 +58,6 @@ split_commits = false
|
|||||||
|
|
||||||
# Regex for preprocessing the commit messages
|
# Regex for preprocessing the commit messages
|
||||||
commit_preprocessors = [
|
commit_preprocessors = [
|
||||||
# Remove PR number from message (we add it back via GitHub integration)
|
|
||||||
{ pattern = '\(#(\d+)\)', replace = '' },
|
|
||||||
# Strip conventional commit prefix for cleaner messages
|
# Strip conventional commit prefix for cleaner messages
|
||||||
# (group header already shows the type)
|
# (group header already shows the type)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
|
|||||||
|
|
||||||
func isLyricsDescription(description string) bool {
|
func isLyricsDescription(description string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(description)) {
|
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||||
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
case
|
||||||
|
"lyrics",
|
||||||
|
"lyric",
|
||||||
|
"unsyncedlyrics",
|
||||||
|
"unsynced lyrics",
|
||||||
|
"uslt",
|
||||||
|
"lrc":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -1566,7 +1572,14 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractAnyCoverArt(filePath string) ([]byte, string, 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))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext == "" {
|
||||||
|
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
||||||
|
}
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".flac":
|
case ".flac":
|
||||||
@@ -1587,7 +1600,19 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
|||||||
return extractOggCoverArt(filePath)
|
return extractOggCoverArt(filePath)
|
||||||
|
|
||||||
case ".m4a":
|
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:
|
default:
|
||||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||||
@@ -1595,6 +1620,10 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||||
|
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||||
cacheKey := filePath
|
cacheKey := filePath
|
||||||
if stat, err := os.Stat(filePath); err == nil {
|
if stat, err := os.Stat(filePath); err == nil {
|
||||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||||
@@ -1611,7 +1640,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
|||||||
return pngPath, nil
|
return pngPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
imageData, mimeType, err := extractAnyCoverArt(filePath)
|
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||||
|
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||||
|
return exec.Command(ffmpegPath, args...)
|
||||||
|
}
|
||||||
|
return exec.Command("ffmpeg", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := ffmpegCommand(args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||||
|
t.Skip("ffmpeg not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||||
|
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||||
|
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||||
|
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||||
|
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"sine=frequency=440:duration=1",
|
||||||
|
"-c:a",
|
||||||
|
"flac",
|
||||||
|
sourceFlac,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"color=c=red:s=32x32:d=1",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
coverPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
sourceFlac,
|
||||||
|
"-b:a",
|
||||||
|
"320k",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
baseMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
runFFmpegTestCommand(
|
||||||
|
t,
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
baseMp3,
|
||||||
|
"-i",
|
||||||
|
coverPath,
|
||||||
|
"-map",
|
||||||
|
"0:a",
|
||||||
|
"-map_metadata",
|
||||||
|
"-1",
|
||||||
|
"-map",
|
||||||
|
"1:0",
|
||||||
|
"-c:v:0",
|
||||||
|
"copy",
|
||||||
|
"-id3v2_version",
|
||||||
|
"3",
|
||||||
|
"-metadata",
|
||||||
|
"title=Test Song",
|
||||||
|
"-metadata",
|
||||||
|
"artist=Test Artist",
|
||||||
|
"-metadata",
|
||||||
|
"lyrics="+lyrics,
|
||||||
|
"-metadata:s:v",
|
||||||
|
"title=Album cover",
|
||||||
|
"-metadata:s:v",
|
||||||
|
"comment=Cover (front)",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
finalMp3,
|
||||||
|
)
|
||||||
|
|
||||||
|
meta, err := ReadID3Tags(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||||
|
}
|
||||||
|
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||||
|
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(finalMp3); err != nil {
|
||||||
|
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-5
@@ -17,6 +17,8 @@ const (
|
|||||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
|
|
||||||
|
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
// Log already printed by upgradeToMaxQuality for Deezer
|
|
||||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
}
|
}
|
||||||
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func upgradeToMaxQuality(coverURL string) string {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify CDN upgrade
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer CDN upgrade
|
|
||||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
return upgradeDeezerCover(coverURL)
|
return upgradeDeezerCover(coverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
|
return upgradeTidalCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||||
|
return upgradeQobuzCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +111,6 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace any size pattern with 1800x1800
|
|
||||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
@@ -112,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return upgraded
|
return upgraded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func upgradeTidalCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeQobuzCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always upgrade small to medium first
|
|
||||||
result := convertSmallToMedium(imageURL)
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
|
|||||||
+27
-24
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
// CueSheet represents a parsed .cue file
|
// CueSheet represents a parsed .cue file
|
||||||
type CueSheet struct {
|
type CueSheet struct {
|
||||||
// Album-level metadata
|
|
||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"`
|
||||||
@@ -32,7 +31,6 @@ type CueTrack struct {
|
|||||||
Performer string `json:"performer"`
|
Performer string `json:"performer"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
Composer string `json:"composer,omitempty"`
|
Composer string `json:"composer,omitempty"`
|
||||||
// Index positions in seconds (fractional)
|
|
||||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||||
}
|
}
|
||||||
@@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle BOM at start of file
|
|
||||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
|
|
||||||
upper := strings.ToUpper(line)
|
upper := strings.ToUpper(line)
|
||||||
|
|
||||||
// REM commands (album-level metadata)
|
|
||||||
if strings.HasPrefix(upper, "REM ") {
|
if strings.HasPrefix(upper, "REM ") {
|
||||||
matches := reRemCommand.FindStringSubmatch(line)
|
matches := reRemCommand.FindStringSubmatch(line)
|
||||||
if len(matches) == 3 {
|
if len(matches) == 3 {
|
||||||
@@ -114,7 +110,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// PERFORMER
|
|
||||||
if strings.HasPrefix(upper, "PERFORMER ") {
|
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||||
value := unquoteCue(line[len("PERFORMER "):])
|
value := unquoteCue(line[len("PERFORMER "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -125,7 +120,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TITLE
|
|
||||||
if strings.HasPrefix(upper, "TITLE ") {
|
if strings.HasPrefix(upper, "TITLE ") {
|
||||||
value := unquoteCue(line[len("TITLE "):])
|
value := unquoteCue(line[len("TITLE "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -136,21 +130,15 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// FILE
|
|
||||||
if strings.HasPrefix(upper, "FILE ") {
|
if strings.HasPrefix(upper, "FILE ") {
|
||||||
rest := line[len("FILE "):]
|
rest := line[len("FILE "):]
|
||||||
// Extract filename and type
|
|
||||||
// Format: FILE "filename.flac" WAVE
|
|
||||||
// or: FILE filename.flac WAVE
|
|
||||||
fname, ftype := parseCueFileLine(rest)
|
fname, ftype := parseCueFileLine(rest)
|
||||||
sheet.FileName = fname
|
sheet.FileName = fname
|
||||||
sheet.FileType = ftype
|
sheet.FileType = ftype
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACK
|
|
||||||
if strings.HasPrefix(upper, "TRACK ") {
|
if strings.HasPrefix(upper, "TRACK ") {
|
||||||
// Save previous track
|
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
@@ -168,7 +156,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// INDEX
|
|
||||||
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||||
parts := strings.Fields(line)
|
parts := strings.Fields(line)
|
||||||
if len(parts) >= 3 {
|
if len(parts) >= 3 {
|
||||||
@@ -184,13 +171,11 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC
|
|
||||||
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||||
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// SONGWRITER (used as composer sometimes)
|
|
||||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||||
value := unquoteCue(line[len("SONGWRITER "):])
|
value := unquoteCue(line[len("SONGWRITER "):])
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
@@ -202,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last track
|
|
||||||
if currentTrack != nil {
|
if currentTrack != nil {
|
||||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||||
}
|
}
|
||||||
@@ -430,7 +414,15 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
|||||||
// entries, one per track. This is used by the library scanner to populate the
|
// 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.
|
// library with individual track entries from a single CUE+FLAC album.
|
||||||
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
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
|
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||||
@@ -441,23 +433,35 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
|
|||||||
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
// - 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)
|
// 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) {
|
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)
|
sheet, err := ParseCueFile(cuePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
resolveBase := cuePath
|
||||||
if audioDir != "" {
|
if audioDir != "" {
|
||||||
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||||
}
|
}
|
||||||
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||||
if audioPath == "" {
|
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
|
// Try to get quality info from the audio file
|
||||||
@@ -540,7 +544,6 @@ func scanCueFileForLibraryInternal(cuePath, audioDir, virtualPathPrefix string,
|
|||||||
duration = int(totalDurationSec - track.StartTime)
|
duration = int(totalDurationSec - track.StartTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a unique ID based on pathBase + track number
|
|
||||||
id := generateLibraryID(fmt.Sprintf("%s#track%d", 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
|
// Use a virtual file path that includes the track number to ensure
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
|
|||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
RecordType string `json:"record_type"`
|
RecordType string `json:"record_type"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
Genres struct {
|
Genres struct {
|
||||||
Data []deezerGenre `json:"data"`
|
Data []deezerGenre `json:"data"`
|
||||||
} `json:"genres"`
|
} `json:"genres"`
|
||||||
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlbumExtendedMetadata struct {
|
type AlbumExtendedMetadata struct {
|
||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
|
Copyright string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
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{
|
result := &AlbumExtendedMetadata{
|
||||||
Genre: strings.Join(genres, ", "),
|
Genre: strings.Join(genres, ", "),
|
||||||
Label: album.Label,
|
Label: album.Label,
|
||||||
|
Copyright: album.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
c.maybeCleanupCachesLocked(now)
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -1178,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
|
|
||||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
@@ -1191,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
errStr := err.Error()
|
||||||
|
|
||||||
// Check if error is retryable
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||||
strings.Contains(errStr, "connection reset") ||
|
strings.Contains(errStr, "connection reset") ||
|
||||||
strings.Contains(errStr, "connection refused") ||
|
strings.Contains(errStr, "connection refused") ||
|
||||||
|
|||||||
@@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if deezerID != "" {
|
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, false); 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)
|
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||||
return availability.DeezerURL, nil
|
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||||
|
if resolvedID != "" {
|
||||||
|
if verifyErr := verifyDeezerTrack(req, resolvedID, true); 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)
|
isrc := strings.TrimSpace(req.ISRC)
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
deezerID = songLinkExtractDeezerTrackID(track)
|
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||||
if deezerID != "" {
|
if resolvedID != "" {
|
||||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
if verifyErr := verifyDeezerTrack(req, resolvedID, false); 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,28 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) 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,
|
||||||
|
ISRC: trackResp.Track.ISRC,
|
||||||
|
Duration: trackResp.Track.DurationMS / 1000,
|
||||||
|
SkipNameVerification: skipNameVerification,
|
||||||
|
}
|
||||||
|
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 {
|
type deezerMusicDLRequest struct {
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -280,7 +321,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
|
|||||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try various response fields for download URL
|
|
||||||
for _, key := range []string{"download_url", "url", "link"} {
|
for _, key := range []string{"download_url", "url", "link"} {
|
||||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
return strings.TrimSpace(urlVal), nil
|
return strings.TrimSpace(urlVal), nil
|
||||||
@@ -394,11 +434,6 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
|
||||||
if err != nil {
|
|
||||||
return DeezerDownloadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -461,6 +496,17 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if downloadErr != nil || deezerURLErr != nil {
|
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)
|
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
||||||
if downloadErr != nil {
|
if downloadErr != nil {
|
||||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: need to build index
|
|
||||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||||
mu := buildLock.(*sync.Mutex)
|
mu := buildLock.(*sync.Mutex)
|
||||||
|
|||||||
+707
-337
File diff suppressed because it is too large
Load Diff
@@ -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,179 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
SpotifyID: "spotify-track-id",
|
||||||
|
AlbumName: "Original Album",
|
||||||
|
ReleaseDate: "2024-01-01",
|
||||||
|
ISRC: "REQ123",
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||||
|
AlbumName: "Resolved Album",
|
||||||
|
ReleaseDate: "",
|
||||||
|
ISRC: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.ReleaseDate != "2024-01-01" {
|
||||||
|
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||||
|
}
|
||||||
|
if req.AlbumName != "Resolved Album" {
|
||||||
|
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||||
|
}
|
||||||
|
if req.ISRC != "REQ123" {
|
||||||
|
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
ReleaseDate: "",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "first",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "",
|
||||||
|
ProviderID: "spotify",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "second",
|
||||||
|
Name: "Song Title",
|
||||||
|
Artists: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "2024-03-09",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected a selected track")
|
||||||
|
}
|
||||||
|
if best.ID != "second" {
|
||||||
|
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
+241
-116
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LoadedExtension struct {
|
type LoadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *ExtensionRuntime
|
runtime *ExtensionRuntime
|
||||||
Enabled bool `json:"enabled"`
|
initialized bool
|
||||||
Error string `json:"error,omitempty"`
|
Enabled bool `json:"enabled"`
|
||||||
DataDir string `json:"data_dir"`
|
Error string `json:"error,omitempty"`
|
||||||
SourceDir string `json:"source_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
IconPath string `json:"icon_path"`
|
SourceDir string `json:"source_dir"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||||
|
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||||
|
if len(settings) == 0 {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make(map[string]interface{}, len(settings))
|
||||||
|
for key, value := range settings {
|
||||||
|
if strings.HasPrefix(key, "_") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||||
|
if ext.VM == nil || ext.runtime == nil {
|
||||||
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if applyStoredSettings && !ext.initialized {
|
||||||
|
settings := getExtensionInitSettings(ext.ID)
|
||||||
|
if len(settings) > 0 {
|
||||||
|
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
ext.Error = err.Error()
|
||||||
|
ext.Enabled = false
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext.initialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.Error = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
return ensureRuntimeReadyLocked(ext, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||||
|
ext.VMMu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ext.VM, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionManager struct {
|
type ExtensionManager struct {
|
||||||
@@ -151,7 +211,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
if exists {
|
if exists {
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
// This is an upgrade - call UpgradeExtension
|
|
||||||
return m.UpgradeExtension(filePath)
|
return m.UpgradeExtension(filePath)
|
||||||
} else if versionCompare == 0 {
|
} else if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||||
@@ -221,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -233,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
func initializeVMLocked(ext *LoadedExtension) error {
|
||||||
|
ext.VM = nil
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.initialized = false
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
@@ -280,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
return initializeVMLocked(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExtensionWithSettingsLocked(
|
||||||
|
ext *LoadedExtension,
|
||||||
|
settings map[string]interface{},
|
||||||
|
) error {
|
||||||
|
if ext.VM == nil {
|
||||||
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsJSON, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to save settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
var settings = %s;
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||||
|
try {
|
||||||
|
extension.initialize(settings);
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no initialize function' };
|
||||||
|
})()
|
||||||
|
`, string(settingsJSON))
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
ext.Error = errMsg
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||||
|
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.initialized = true
|
||||||
|
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCleanupLocked(ext *LoadedExtension) error {
|
||||||
|
if ext.VM != nil {
|
||||||
|
script := `
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||||
|
try {
|
||||||
|
extension.cleanup();
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no cleanup function' };
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||||
|
errMsg := "unknown error"
|
||||||
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||||
|
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardownVMLocked(ext *LoadedExtension) {
|
||||||
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
if ext.runtime != nil {
|
||||||
|
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||||
|
}
|
||||||
|
ext.runtime.closeStorageFlusher()
|
||||||
|
}
|
||||||
|
ext.runtime = nil
|
||||||
|
ext.VM = nil
|
||||||
|
ext.initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -289,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM != nil {
|
ext.VMMu.Lock()
|
||||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
teardownVMLocked(ext)
|
||||||
if err != nil {
|
ext.VMMu.Unlock()
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
|
||||||
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
|
||||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ext.runtime != nil {
|
|
||||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
|
||||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
|
||||||
}
|
|
||||||
ext.runtime.closeStorageFlusher()
|
|
||||||
ext.runtime = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(m.extensions, extensionID)
|
delete(m.extensions, extensionID)
|
||||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||||
@@ -342,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.Enabled = enabled
|
if enabled {
|
||||||
|
ext.Enabled = true
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
|
store := GetExtensionSettingsStore()
|
||||||
|
ext.Enabled = false
|
||||||
|
_ = store.Set(extensionID, "_enabled", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext.Enabled = false
|
||||||
|
ext.Error = ""
|
||||||
|
ext.VMMu.Lock()
|
||||||
|
teardownVMLocked(ext)
|
||||||
|
ext.VMMu.Unlock()
|
||||||
|
}
|
||||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||||
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
@@ -429,7 +623,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
SourceDir: dirPath,
|
SourceDir: dirPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore enabled state from settings store
|
|
||||||
store := GetExtensionSettingsStore()
|
store := GetExtensionSettingsStore()
|
||||||
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
||||||
if enabled, ok := enabledVal.(bool); ok {
|
if enabled, ok := enabledVal.(bool); ok {
|
||||||
@@ -438,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.extensions[manifest.Name] = ext
|
m.extensions[manifest.Name] = ext
|
||||||
@@ -592,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
SourceDir: extDir,
|
SourceDir: extDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.initializeVM(ext); err != nil {
|
if wasEnabled {
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
|
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||||
|
}
|
||||||
|
} else if err := validateExtensionLoad(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
ext.Enabled = false
|
ext.Enabled = false
|
||||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -792,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
ext.VMMu.Lock()
|
||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
defer ext.VMMu.Unlock()
|
||||||
}
|
|
||||||
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Failed to save settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
|
||||||
(function() {
|
|
||||||
var settings = %s;
|
|
||||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
|
||||||
try {
|
|
||||||
extension.initialize(settings);
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no initialize function' };
|
|
||||||
})()
|
|
||||||
`, string(settingsJSON))
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
ext.Error = errMsg
|
|
||||||
ext.Enabled = false
|
|
||||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
|
||||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Extension] Initialized %s\n", extensionID)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||||
@@ -856,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ext.VMMu.Lock()
|
||||||
script := `
|
defer ext.VMMu.Unlock()
|
||||||
(function() {
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
|
||||||
try {
|
|
||||||
extension.cleanup();
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no cleanup function' };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
|
|
||||||
result, err := ext.VM.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
|
||||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := resultMap["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
|
||||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -919,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
if err := ext.ensureRuntimeReady(); err != nil {
|
||||||
return nil, fmt.Errorf("extension VM not initialized")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ext.Enabled {
|
if !ext.Enabled {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type ExtArtistMetadata struct {
|
|||||||
HeaderImage string `json:"header_image,omitempty"`
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
Listeners int `json:"listeners,omitempty"`
|
Listeners int `json:"listeners,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||||
|
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
}
|
}
|
||||||
@@ -124,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||||
|
vm, err := p.extension.lockReadyVM()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.vm = vm
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||||
if !p.extension.Manifest.IsMetadataProvider() {
|
if !p.extension.Manifest.IsMetadataProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||||
@@ -132,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -191,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -239,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -290,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -327,6 +341,12 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||||||
}
|
}
|
||||||
|
|
||||||
artist.ProviderID = p.extension.ID
|
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
|
return &artist, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
trackJSON, err := json.Marshal(track)
|
trackJSON, err := json.Marshal(track)
|
||||||
@@ -398,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -445,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -484,9 +508,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
|||||||
return &urlResult, nil
|
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) {
|
func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||||
if !p.extension.Manifest.IsDownloadProvider() {
|
if !p.extension.Manifest.IsDownloadProvider() {
|
||||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||||
}
|
}
|
||||||
@@ -494,9 +518,18 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &ExtDownloadResult{
|
||||||
|
Success: false,
|
||||||
|
ErrorMessage: err.Error(),
|
||||||
|
ErrorType: "init_error",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
if p.extension.runtime != nil {
|
||||||
|
p.extension.runtime.setActiveDownloadItemID(itemID)
|
||||||
|
defer p.extension.runtime.clearActiveDownloadItemID()
|
||||||
|
}
|
||||||
|
|
||||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) > 0 {
|
if len(call.Arguments) > 0 {
|
||||||
@@ -600,8 +633,30 @@ func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) (
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var allTracks []ExtTrackMetadata
|
providerByID := make(map[string]*ExtensionProviderWrapper, len(providers))
|
||||||
|
orderedProviders := make([]*ExtensionProviderWrapper, 0, len(providers))
|
||||||
for _, provider := range 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)
|
result, err := provider.SearchTracks(query, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err)
|
||||||
@@ -621,6 +676,8 @@ var providerPriorityMu sync.RWMutex
|
|||||||
var metadataProviderPriority []string
|
var metadataProviderPriority []string
|
||||||
var metadataProviderPriorityMu sync.RWMutex
|
var metadataProviderPriorityMu sync.RWMutex
|
||||||
|
|
||||||
|
var searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks
|
||||||
|
|
||||||
func SetProviderPriority(providerIDs []string) {
|
func SetProviderPriority(providerIDs []string) {
|
||||||
providerPriorityMu.Lock()
|
providerPriorityMu.Lock()
|
||||||
defer providerPriorityMu.Unlock()
|
defer providerPriorityMu.Unlock()
|
||||||
@@ -645,7 +702,7 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
|||||||
metadataProviderPriorityMu.Lock()
|
metadataProviderPriorityMu.Lock()
|
||||||
defer metadataProviderPriorityMu.Unlock()
|
defer metadataProviderPriorityMu.Unlock()
|
||||||
|
|
||||||
sanitized := make([]string, 0, len(providerIDs)+1)
|
sanitized := make([]string, 0, len(providerIDs)+3)
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, providerID := range providerIDs {
|
for _, providerID := range providerIDs {
|
||||||
providerID = strings.TrimSpace(providerID)
|
providerID = strings.TrimSpace(providerID)
|
||||||
@@ -658,8 +715,12 @@ func SetMetadataProviderPriority(providerIDs []string) {
|
|||||||
seen[providerID] = struct{}{}
|
seen[providerID] = struct{}{}
|
||||||
sanitized = append(sanitized, providerID)
|
sanitized = append(sanitized, providerID)
|
||||||
}
|
}
|
||||||
if _, exists := seen["deezer"]; !exists {
|
for _, providerID := range []string{"deezer", "qobuz", "tidal"} {
|
||||||
sanitized = append([]string{"deezer"}, sanitized...)
|
if _, exists := seen[providerID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[providerID] = struct{}{}
|
||||||
|
sanitized = append(sanitized, providerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataProviderPriority = sanitized
|
metadataProviderPriority = sanitized
|
||||||
@@ -671,7 +732,7 @@ func GetMetadataProviderPriority() []string {
|
|||||||
defer metadataProviderPriorityMu.RUnlock()
|
defer metadataProviderPriorityMu.RUnlock()
|
||||||
|
|
||||||
if len(metadataProviderPriority) == 0 {
|
if len(metadataProviderPriority) == 0 {
|
||||||
return []string{"deezer"}
|
return []string{"deezer", "qobuz", "tidal"}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]string, len(metadataProviderPriority))
|
result := make([]string, len(metadataProviderPriority))
|
||||||
@@ -688,6 +749,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) {
|
func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) {
|
||||||
priority := GetProviderPriority()
|
priority := GetProviderPriority()
|
||||||
extManager := GetExtensionManager()
|
extManager := GetExtensionManager()
|
||||||
@@ -783,6 +1003,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if enrichedTrack.Artists != "" {
|
if enrichedTrack.Artists != "" {
|
||||||
req.ArtistName = 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 == "" {
|
if enrichedTrack.Label != "" && req.Label == "" {
|
||||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||||
req.Label = enrichedTrack.Label
|
req.Label = enrichedTrack.Label
|
||||||
@@ -803,6 +1041,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 != "" &&
|
if req.Source != "" &&
|
||||||
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||||
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
|
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
|
||||||
@@ -823,7 +1132,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) {
|
result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
normalized := float64(percent) / 100.0
|
normalized := float64(percent) / 100.0
|
||||||
if normalized < 0 {
|
if normalized < 0 {
|
||||||
@@ -896,6 +1205,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
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,7 +1279,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||||
|
|
||||||
if isBuiltInProvider(providerIDNormalized) {
|
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)
|
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
@@ -961,6 +1295,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.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 {
|
} else if err != nil {
|
||||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -1022,7 +1360,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
normalized := float64(percent) / 100.0
|
normalized := float64(percent) / 100.0
|
||||||
if normalized < 0 {
|
if normalized < 0 {
|
||||||
@@ -1168,6 +1506,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
TrackNumber: qobuzResult.TrackNumber,
|
TrackNumber: qobuzResult.TrackNumber,
|
||||||
DiscNumber: qobuzResult.DiscNumber,
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
ISRC: qobuzResult.ISRC,
|
ISRC: qobuzResult.ISRC,
|
||||||
|
CoverURL: qobuzResult.CoverURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
@@ -1210,6 +1549,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
|||||||
TrackNumber: result.TrackNumber,
|
TrackNumber: result.TrackNumber,
|
||||||
DiscNumber: result.DiscNumber,
|
DiscNumber: result.DiscNumber,
|
||||||
ISRC: result.ISRC,
|
ISRC: result.ISRC,
|
||||||
|
CoverURL: result.CoverURL,
|
||||||
Genre: req.Genre,
|
Genre: req.Genre,
|
||||||
Label: req.Label,
|
Label: req.Label,
|
||||||
Copyright: req.Copyright,
|
Copyright: req.Copyright,
|
||||||
@@ -1312,8 +1652,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
if options == nil {
|
if options == nil {
|
||||||
@@ -1393,8 +1734,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -1449,6 +1791,12 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
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 {
|
for i := range handleResult.Artist.TopTracks {
|
||||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1472,8 +1820,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||||
@@ -1542,8 +1891,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
@@ -1604,8 +1954,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
metadataJSON, _ := json.Marshal(metadata)
|
metadataJSON, _ := json.Marshal(metadata)
|
||||||
@@ -1862,8 +2213,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
|||||||
if !p.extension.Enabled {
|
if !p.extension.Enabled {
|
||||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||||
}
|
}
|
||||||
|
if err := p.lockReadyVM(); err != nil {
|
||||||
p.extension.VMMu.Lock()
|
return nil, err
|
||||||
|
}
|
||||||
defer p.extension.VMMu.Unlock()
|
defer p.extension.VMMu.Unlock()
|
||||||
|
|
||||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||||
|
|||||||
@@ -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,17 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionRuntime struct {
|
type ExtensionRuntime struct {
|
||||||
extensionID string
|
extensionID string
|
||||||
manifest *ExtensionManifest
|
manifest *ExtensionManifest
|
||||||
settings map[string]interface{}
|
settings map[string]interface{}
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
cookieJar http.CookieJar
|
downloadClient *http.Client
|
||||||
dataDir string
|
cookieJar http.CookieJar
|
||||||
vm *goja.Runtime
|
dataDir string
|
||||||
|
vm *goja.Runtime
|
||||||
|
|
||||||
|
activeDownloadMu sync.RWMutex
|
||||||
|
activeDownloadItemID string
|
||||||
|
|
||||||
storageMu sync.RWMutex
|
storageMu sync.RWMutex
|
||||||
storageCache map[string]interface{}
|
storageCache map[string]interface{}
|
||||||
@@ -132,13 +136,38 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
storageFlushDelay: defaultStorageFlushDelay,
|
storageFlushDelay: defaultStorageFlushDelay,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||||
|
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||||
|
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||||
|
r.activeDownloadMu.Lock()
|
||||||
|
defer r.activeDownloadMu.Unlock()
|
||||||
|
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
||||||
|
r.activeDownloadMu.Lock()
|
||||||
|
defer r.activeDownloadMu.Unlock()
|
||||||
|
r.activeDownloadItemID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
||||||
|
r.activeDownloadMu.RLock()
|
||||||
|
defer r.activeDownloadMu.RUnlock()
|
||||||
|
return r.activeDownloadItemID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: timeout,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
}
|
}
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
@@ -165,9 +194,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
runtime.httpClient = client
|
return client
|
||||||
|
|
||||||
return runtime
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedirectBlockedError struct {
|
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")
|
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 {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -200,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
contentLength := resp.ContentLength
|
contentLength := resp.ContentLength
|
||||||
|
activeItemID := r.getActiveDownloadItemID()
|
||||||
|
if activeItemID != "" && contentLength > 0 {
|
||||||
|
SetItemBytesTotal(activeItemID, contentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||||
|
if activeItemID != "" {
|
||||||
|
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||||
|
}
|
||||||
|
|
||||||
var written int64
|
var written int64
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
for {
|
for {
|
||||||
nr, er := resp.Body.Read(buf)
|
nr, er := resp.Body.Read(buf)
|
||||||
if nr > 0 {
|
if nr > 0 {
|
||||||
nw, ew := out.Write(buf[0:nr])
|
nw, ew := progressWriter.Write(buf[0:nr])
|
||||||
if nw < 0 || nr < nw {
|
if nw < 0 || nr < nw {
|
||||||
nw = 0
|
nw = 0
|
||||||
if ew == nil {
|
if ew == nil {
|
||||||
@@ -215,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
written += int64(nw)
|
written += int64(nw)
|
||||||
if ew != nil {
|
if ew != nil {
|
||||||
|
if ew == ErrDownloadCancelled {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": "download cancelled",
|
||||||
|
})
|
||||||
|
}
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||||
|
|||||||
+162
-49
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,7 @@ const (
|
|||||||
CategoryIntegration = "integration"
|
CategoryIntegration = "integration"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StoreExtension struct {
|
type storeExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
@@ -40,7 +41,7 @@ type StoreExtension struct {
|
|||||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getDisplayName() string {
|
func (e *storeExtension) getDisplayName() string {
|
||||||
if e.DisplayName != "" {
|
if e.DisplayName != "" {
|
||||||
return e.DisplayName
|
return e.DisplayName
|
||||||
}
|
}
|
||||||
@@ -50,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string {
|
|||||||
return e.Name
|
return e.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getDownloadURL() string {
|
func (e *storeExtension) getDownloadURL() string {
|
||||||
if e.DownloadURL != "" {
|
if e.DownloadURL != "" {
|
||||||
return e.DownloadURL
|
return e.DownloadURL
|
||||||
}
|
}
|
||||||
return e.DownloadURLAlt
|
return e.DownloadURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getIconURL() string {
|
func (e *storeExtension) getIconURL() string {
|
||||||
if e.IconURL != "" {
|
if e.IconURL != "" {
|
||||||
return e.IconURL
|
return e.IconURL
|
||||||
}
|
}
|
||||||
return e.IconURLAlt
|
return e.IconURLAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) getMinAppVersion() string {
|
func (e *storeExtension) getMinAppVersion() string {
|
||||||
if e.MinAppVersion != "" {
|
if e.MinAppVersion != "" {
|
||||||
return e.MinAppVersion
|
return e.MinAppVersion
|
||||||
}
|
}
|
||||||
return e.MinAppVersionAlt
|
return e.MinAppVersionAlt
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoreRegistry struct {
|
type storeRegistry struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
Extensions []StoreExtension `json:"extensions"`
|
Extensions []storeExtension `json:"extensions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoreExtensionResponse struct {
|
type storeExtensionResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
@@ -96,8 +97,8 @@ type StoreExtensionResponse struct {
|
|||||||
HasUpdate bool `json:"has_update"`
|
HasUpdate bool `json:"has_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||||
return StoreExtensionResponse{
|
resp := storeExtensionResponse{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
DisplayName: e.getDisplayName(),
|
DisplayName: e.getDisplayName(),
|
||||||
@@ -107,55 +108,89 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
|||||||
DownloadURL: e.getDownloadURL(),
|
DownloadURL: e.getDownloadURL(),
|
||||||
IconURL: e.getIconURL(),
|
IconURL: e.getIconURL(),
|
||||||
Category: e.Category,
|
Category: e.Category,
|
||||||
Tags: e.Tags,
|
|
||||||
Downloads: e.Downloads,
|
Downloads: e.Downloads,
|
||||||
UpdatedAt: e.UpdatedAt,
|
UpdatedAt: e.UpdatedAt,
|
||||||
MinAppVersion: e.getMinAppVersion(),
|
MinAppVersion: e.getMinAppVersion(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
resp.Tags = append([]string(nil), e.Tags...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionStore struct {
|
type extensionStore struct {
|
||||||
registryURL string
|
registryURL string
|
||||||
cacheDir string
|
cacheDir string
|
||||||
cache *StoreRegistry
|
cache *storeRegistry
|
||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
cacheTime time.Time
|
cacheTime time.Time
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
extensionStore *ExtensionStore
|
globalExtensionStore *extensionStore
|
||||||
extensionStoreMu sync.Mutex
|
extensionStoreMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
cacheTTL = 30 * time.Minute
|
||||||
cacheTTL = 30 * time.Minute
|
cacheFileName = "store_cache.json"
|
||||||
cacheFileName = "store_cache.json"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
func initExtensionStore(cacheDir string) *extensionStore {
|
||||||
extensionStoreMu.Lock()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
|
|
||||||
if extensionStore == nil {
|
if globalExtensionStore == nil {
|
||||||
extensionStore = &ExtensionStore{
|
globalExtensionStore = &extensionStore{
|
||||||
registryURL: defaultRegistryURL,
|
registryURL: "", // No default - user must provide a registry URL
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
cacheTTL: cacheTTL,
|
cacheTTL: cacheTTL,
|
||||||
}
|
}
|
||||||
extensionStore.loadDiskCache()
|
globalExtensionStore.loadDiskCache()
|
||||||
}
|
}
|
||||||
return extensionStore
|
return globalExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetExtensionStore() *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()
|
extensionStoreMu.Lock()
|
||||||
defer extensionStoreMu.Unlock()
|
defer extensionStoreMu.Unlock()
|
||||||
return extensionStore
|
return globalExtensionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) loadDiskCache() {
|
func (s *extensionStore) loadDiskCache() {
|
||||||
if s.cacheDir == "" {
|
if s.cacheDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -167,7 +202,7 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cacheData struct {
|
var cacheData struct {
|
||||||
Registry StoreRegistry `json:"registry"`
|
Registry storeRegistry `json:"registry"`
|
||||||
CacheTime int64 `json:"cache_time"`
|
CacheTime int64 `json:"cache_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,13 +215,13 @@ func (s *ExtensionStore) loadDiskCache() {
|
|||||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) saveDiskCache() {
|
func (s *extensionStore) saveDiskCache() {
|
||||||
if s.cacheDir == "" || s.cache == nil {
|
if s.cacheDir == "" || s.cache == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheData := struct {
|
cacheData := struct {
|
||||||
Registry StoreRegistry `json:"registry"`
|
Registry storeRegistry `json:"registry"`
|
||||||
CacheTime int64 `json:"cache_time"`
|
CacheTime int64 `json:"cache_time"`
|
||||||
}{
|
}{
|
||||||
Registry: *s.cache,
|
Registry: *s.cache,
|
||||||
@@ -202,10 +237,14 @@ func (s *ExtensionStore) saveDiskCache() {
|
|||||||
os.WriteFile(cachePath, data, 0644)
|
os.WriteFile(cachePath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
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 {
|
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||||
return s.cache, nil
|
return s.cache, nil
|
||||||
@@ -241,7 +280,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var registry StoreRegistry
|
var registry storeRegistry
|
||||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||||
}
|
}
|
||||||
@@ -254,8 +293,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
|||||||
return ®istry, nil
|
return ®istry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.fetchRegistry(forceRefresh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -269,29 +308,32 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||||
for i, ext := range registry.Extensions {
|
|
||||||
resp := ext.ToResponse()
|
|
||||||
|
|
||||||
|
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
|
||||||
|
for i := range registry.Extensions {
|
||||||
|
ext := ®istry.Extensions[i]
|
||||||
|
resp := ext.toResponse()
|
||||||
if installedVersion, ok := installed[ext.ID]; ok {
|
if installedVersion, ok := installed[ext.ID]; ok {
|
||||||
resp.IsInstalled = true
|
resp.IsInstalled = true
|
||||||
resp.InstalledVersion = installedVersion
|
resp.InstalledVersion = installedVersion
|
||||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
result[i] = resp
|
result = append(result, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||||
registry, err := s.FetchRegistry(false)
|
registry, err := s.fetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ext *StoreExtension
|
var ext *storeExtension
|
||||||
for _, e := range registry.Extensions {
|
for _, e := range registry.Extensions {
|
||||||
if e.ID == extensionID {
|
if e.ID == extensionID {
|
||||||
ext = &e
|
ext = &e
|
||||||
@@ -336,6 +378,80 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
|||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func requireHTTPSURL(rawURL string, context string) error {
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return fmt.Errorf("%s URL is empty", context)
|
return fmt.Errorf("%s URL is empty", context)
|
||||||
@@ -350,7 +466,7 @@ func requireHTTPSURL(rawURL string, context string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) GetCategories() []string {
|
func (s *extensionStore) getCategories() []string {
|
||||||
return []string{
|
return []string{
|
||||||
CategoryMetadata,
|
CategoryMetadata,
|
||||||
CategoryDownload,
|
CategoryDownload,
|
||||||
@@ -360,8 +476,8 @@ func (s *ExtensionStore) GetCategories() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
|
||||||
extensions, err := s.GetExtensionsWithStatus()
|
extensions, err := s.getExtensionsWithStatus(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -370,22 +486,19 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return extensions, nil
|
return extensions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []StoreExtensionResponse
|
result := make([]storeExtensionResponse, 0, len(extensions))
|
||||||
queryLower := toLower(query)
|
queryLower := toLower(query)
|
||||||
|
|
||||||
for _, ext := range extensions {
|
for _, ext := range extensions {
|
||||||
// Filter by category
|
|
||||||
if category != "" && ext.Category != category {
|
if category != "" && ext.Category != category {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by query
|
|
||||||
if query != "" {
|
if query != "" {
|
||||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Author, queryLower) {
|
!containsIgnoreCase(ext.Author, queryLower) {
|
||||||
// Check tags
|
|
||||||
found := false
|
found := false
|
||||||
for _, tag := range ext.Tags {
|
for _, tag := range ext.Tags {
|
||||||
if containsIgnoreCase(tag, queryLower) {
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
@@ -405,7 +518,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExtensionStore) ClearCache() {
|
func (s *extensionStore) clearCache() {
|
||||||
s.cacheMu.Lock()
|
s.cacheMu.Lock()
|
||||||
defer s.cacheMu.Unlock()
|
defer s.cacheMu.Unlock()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.50.0
|
||||||
|
golang.org/x/text v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -24,6 +25,5 @@ require (
|
|||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
@@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
|||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
|
||||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
|
||||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func getRandomUserAgent() string {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second
|
DefaultTimeout = 60 * time.Second
|
||||||
DownloadTimeout = 120 * time.Second
|
DownloadTimeout = 24 * time.Hour
|
||||||
SongLinkTimeout = 30 * time.Second
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second
|
DefaultRetryDelay = 1 * time.Second
|
||||||
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for ISP blocking via HTTP status codes
|
|
||||||
// Some ISPs return 403 or 451 when blocking content
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
bodyStr := strings.ToLower(string(body))
|
bodyStr := strings.ToLower(string(body))
|
||||||
|
|
||||||
// Check if response looks like ISP blocking page
|
|
||||||
ispBlockingIndicators := []string{
|
ispBlockingIndicators := []string{
|
||||||
"blocked", "forbidden", "access denied", "not available in your",
|
"blocked", "forbidden", "access denied", "not available in your",
|
||||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||||
@@ -346,11 +343,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
|||||||
return min(nextDelay, config.MaxDelay)
|
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 {
|
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||||
retryAfter := resp.Header.Get("Retry-After")
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
if retryAfter == "" {
|
if retryAfter == "" {
|
||||||
return 60 * time.Second // Default wait time
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
@@ -364,7 +362,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 60 * time.Second // Default
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
@@ -517,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if ISP blocking was detected
|
|
||||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
ispErr := IsISPBlocking(err, requestURL)
|
ispErr := IsISPBlocking(err, requestURL)
|
||||||
if ispErr != nil {
|
if ispErr != nil {
|
||||||
@@ -552,7 +549,6 @@ func extractDomain(rawURL string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ISP blocking is detected, returns a more descriptive error
|
|
||||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
|
|
||||||
resp, err := sharedClient.Do(req)
|
resp, err := sharedClient.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check for Cloudflare challenge page (403 with specific markers)
|
|
||||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if error might be TLS-related (Cloudflare blocking)
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||||
strings.Contains(errStr, "handshake") ||
|
strings.Contains(errStr, "handshake") ||
|
||||||
|
|||||||
+191
-81
@@ -1,10 +1,12 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -71,6 +73,11 @@ type libraryAudioFileInfo struct {
|
|||||||
modTime int64
|
modTime int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type scannedCueFileInfo struct {
|
||||||
|
sheet *CueSheet
|
||||||
|
audioPath string
|
||||||
|
}
|
||||||
|
|
||||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||||
var files []libraryAudioFileInfo
|
var files []libraryAudioFileInfo
|
||||||
|
|
||||||
@@ -144,12 +151,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
return "[]", err
|
return "[]", err
|
||||||
}
|
}
|
||||||
|
|
||||||
audioFiles := make([]string, 0, len(audioFileInfos))
|
totalFiles := len(audioFileInfos)
|
||||||
for _, fileInfo := range audioFileInfos {
|
|
||||||
audioFiles = append(audioFiles, fileInfo.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
totalFiles := len(audioFiles)
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
@@ -169,22 +171,29 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
|
|
||||||
// Track audio files referenced by .cue sheets to avoid duplicates
|
// Track audio files referenced by .cue sheets to avoid duplicates
|
||||||
cueReferencedAudioFiles := make(map[string]bool)
|
cueReferencedAudioFiles := make(map[string]bool)
|
||||||
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
|
|
||||||
// First pass: scan .cue files to collect referenced audio paths
|
// 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))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
sheet, err := ParseCueFile(filePath)
|
sheet, err := ParseCueFile(filePath)
|
||||||
if err == nil && sheet.FileName != "" {
|
if err == nil && sheet.FileName != "" {
|
||||||
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
audioPath := ResolveCueAudioPath(filePath, sheet.FileName)
|
||||||
if audioPath != "" {
|
if audioPath != "" {
|
||||||
|
parsedCueFiles[filePath] = scannedCueFileInfo{
|
||||||
|
sheet: sheet,
|
||||||
|
audioPath: audioPath,
|
||||||
|
}
|
||||||
cueReferencedAudioFiles[audioPath] = true
|
cueReferencedAudioFiles[audioPath] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, filePath := range audioFiles {
|
for i, fileInfo := range audioFileInfos {
|
||||||
|
filePath := fileInfo.path
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
return "[]", fmt.Errorf("scan cancelled")
|
return "[]", fmt.Errorf("scan cancelled")
|
||||||
@@ -201,7 +210,20 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
|
|
||||||
// Handle .cue files: produce multiple track results
|
// Handle .cue files: produce multiple track results
|
||||||
if ext == ".cue" {
|
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 {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||||
@@ -212,14 +234,12 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files that are referenced by a .cue sheet
|
|
||||||
// (they will be represented by the cue sheet's track entries instead)
|
|
||||||
if cueReferencedAudioFiles[filePath] {
|
if cueReferencedAudioFiles[filePath] {
|
||||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanAudioFile(filePath, scanTime)
|
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||||
@@ -245,7 +265,15 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, 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{
|
result := &LibraryScanResult{
|
||||||
ID: generateLibraryID(filePath),
|
ID: generateLibraryID(filePath),
|
||||||
@@ -254,15 +282,17 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
Format: strings.TrimPrefix(ext, "."),
|
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()
|
result.FileModTime = info.ModTime().UnixMilli()
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryCoverCacheMu.RLock()
|
libraryCoverCacheMu.RLock()
|
||||||
coverCacheDir := libraryCoverCacheDir
|
coverCacheDir := libraryCoverCacheDir
|
||||||
libraryCoverCacheMu.RUnlock()
|
libraryCoverCacheMu.RUnlock()
|
||||||
if coverCacheDir != "" && ext != ".m4a" {
|
if coverCacheDir != "" {
|
||||||
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
||||||
if err == nil && coverPath != "" {
|
if err == nil && coverPath != "" {
|
||||||
result.CoverPath = coverPath
|
result.CoverPath = coverPath
|
||||||
}
|
}
|
||||||
@@ -276,15 +306,31 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
|||||||
case ".mp3":
|
case ".mp3":
|
||||||
return scanMP3File(filePath, result)
|
return scanMP3File(filePath, result)
|
||||||
case ".opus", ".ogg":
|
case ".opus", ".ogg":
|
||||||
return scanOggFile(filePath, result)
|
return scanOggFile(filePath, result, displayNameHint)
|
||||||
default:
|
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 == "" {
|
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 == "" {
|
if result.ArtistName == "" {
|
||||||
result.ArtistName = "Unknown Artist"
|
result.ArtistName = "Unknown Artist"
|
||||||
@@ -297,7 +343,7 @@ func applyDefaultLibraryMetadata(filePath string, result *LibraryScanResult) {
|
|||||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadMetadata(filePath)
|
metadata, err := ReadMetadata(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, "", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -319,26 +365,43 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, result)
|
applyDefaultLibraryMetadata(filePath, "", result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
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)
|
quality, err := GetM4AQuality(filePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
result.SampleRate = quality.SampleRate
|
result.SampleRate = quality.SampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
return scanFromFilename(filePath, result)
|
applyDefaultLibraryMetadata(filePath, "", result)
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
metadata, err := ReadID3Tags(filePath)
|
metadata, err := ReadID3Tags(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||||
return scanFromFilename(filePath, result)
|
return scanFromFilename(filePath, "", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.TrackName = metadata.Title
|
result.TrackName = metadata.Title
|
||||||
@@ -365,16 +428,16 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, result)
|
applyDefaultLibraryMetadata(filePath, "", result)
|
||||||
|
|
||||||
return result, nil
|
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)
|
metadata, err := ReadOggVorbisComments(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
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
|
result.TrackName = metadata.Title
|
||||||
@@ -397,13 +460,14 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDefaultLibraryMetadata(filePath, result)
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
|
||||||
|
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
|
||||||
|
|
||||||
parts := strings.SplitN(filename, " - ", 2)
|
parts := strings.SplitN(filename, " - ", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
@@ -426,7 +490,7 @@ func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanR
|
|||||||
|
|
||||||
dir := filepath.Dir(filePath)
|
dir := filepath.Dir(filePath)
|
||||||
result.AlbumName = filepath.Base(dir)
|
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"
|
result.AlbumName = "Unknown Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,8 +537,12 @@ func CancelLibraryScan() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ReadAudioMetadata(filePath string) (string, error) {
|
func ReadAudioMetadata(filePath string) (string, error) {
|
||||||
|
return ReadAudioMetadataWithDisplayName(filePath, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
|
||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
result, err := scanAudioFile(filePath, scanTime)
|
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -487,10 +555,43 @@ func ReadAudioMetadata(filePath string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
|
||||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
existingFiles := make(map[string]int64)
|
||||||
// Only files that are new or have changed modification time will be scanned
|
if snapshotPath == "" {
|
||||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
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 == "" {
|
if folderPath == "" {
|
||||||
return "{}", fmt.Errorf("folder path is empty")
|
return "{}", fmt.Errorf("folder path is empty")
|
||||||
}
|
}
|
||||||
@@ -503,13 +604,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
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))
|
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
@@ -538,44 +632,27 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
libraryScanProgress.TotalFiles = totalFiles
|
libraryScanProgress.TotalFiles = totalFiles
|
||||||
libraryScanProgressMu.Unlock()
|
libraryScanProgressMu.Unlock()
|
||||||
|
|
||||||
// Find files to scan (new or modified)
|
|
||||||
var filesToScan []libraryAudioFileInfo
|
var filesToScan []libraryAudioFileInfo
|
||||||
skippedCount := 0
|
skippedCount := 0
|
||||||
|
existingCueTrackModTimes := make(map[string]int64)
|
||||||
// Build a set of existing CUE virtual path base files for incremental matching.
|
for existingPath, modTime := range existingFiles {
|
||||||
// CUE tracks are stored with virtual paths like "/path/album.cue#track01".
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
// We need to match these against the actual .cue file's modTime.
|
baseCuePath := existingPath[:idx]
|
||||||
cueBaseModTimes := make(map[string]int64) // base cue path -> modTime from disk
|
if _, exists := existingCueTrackModTimes[baseCuePath]; !exists {
|
||||||
for _, f := range currentFiles {
|
existingCueTrackModTimes[baseCuePath] = modTime
|
||||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
}
|
||||||
cueBaseModTimes[f.path] = f.modTime
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range currentFiles {
|
for _, f := range currentFiles {
|
||||||
existingModTime, exists := existingFiles[f.path]
|
existingModTime, exists := existingFiles[f.path]
|
||||||
if !exists {
|
if !exists {
|
||||||
// For .cue files, also check if any virtual path entries exist
|
|
||||||
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
|
||||||
hasCueTracks := false
|
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
|
||||||
for existingPath := range existingFiles {
|
if f.modTime == cueTrackModTime {
|
||||||
if strings.HasPrefix(existingPath, f.path+"#track") {
|
skippedCount++
|
||||||
hasCueTracks = true
|
} else {
|
||||||
break
|
filesToScan = append(filesToScan, f)
|
||||||
}
|
|
||||||
}
|
|
||||||
if 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -590,14 +667,11 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
|
|
||||||
var deletedPaths []string
|
var deletedPaths []string
|
||||||
for existingPath := range existingFiles {
|
for existingPath := range existingFiles {
|
||||||
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
|
|
||||||
// check if the base .cue file still exists on disk
|
|
||||||
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
|
||||||
baseCuePath := existingPath[:idx]
|
baseCuePath := existingPath[:idx]
|
||||||
if currentPathSet[baseCuePath] {
|
if currentPathSet[baseCuePath] {
|
||||||
continue // Base .cue file still exists, not deleted
|
continue
|
||||||
}
|
}
|
||||||
// Base CUE file is gone, mark virtual path as deleted
|
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
} else if !currentPathSet[existingPath] {
|
} else if !currentPathSet[existingPath] {
|
||||||
deletedPaths = append(deletedPaths, existingPath)
|
deletedPaths = append(deletedPaths, existingPath)
|
||||||
@@ -628,8 +702,8 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||||
errorCount := 0
|
errorCount := 0
|
||||||
|
|
||||||
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
|
|
||||||
cueReferencedAudioFilesInc := make(map[string]bool)
|
cueReferencedAudioFilesInc := make(map[string]bool)
|
||||||
|
parsedCueFiles := make(map[string]scannedCueFileInfo)
|
||||||
for _, f := range filesToScan {
|
for _, f := range filesToScan {
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
@@ -637,6 +711,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
if err == nil && sheet.FileName != "" {
|
if err == nil && sheet.FileName != "" {
|
||||||
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
audioPath := ResolveCueAudioPath(f.path, sheet.FileName)
|
||||||
if audioPath != "" {
|
if audioPath != "" {
|
||||||
|
parsedCueFiles[f.path] = scannedCueFileInfo{
|
||||||
|
sheet: sheet,
|
||||||
|
audioPath: audioPath,
|
||||||
|
}
|
||||||
cueReferencedAudioFilesInc[audioPath] = true
|
cueReferencedAudioFilesInc[audioPath] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -658,9 +736,21 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
// Handle .cue files: produce multiple track results
|
|
||||||
if ext == ".cue" {
|
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 {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||||
@@ -670,12 +760,11 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip audio files referenced by .cue sheets
|
|
||||||
if cueReferencedAudioFilesInc[f.path] {
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanAudioFile(f.path, scanTime)
|
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||||
@@ -709,3 +798,24 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string,
|
|||||||
|
|
||||||
return string(jsonBytes), nil
|
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
-70
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -82,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider names
|
|
||||||
validNames := map[string]bool{
|
validNames := map[string]bool{
|
||||||
LyricsProviderSpotifyAPI: true,
|
LyricsProviderSpotifyAPI: true,
|
||||||
LyricsProviderLRCLIB: true,
|
LyricsProviderLRCLIB: true,
|
||||||
@@ -104,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsProviderOrder returns the current lyrics provider order.
|
|
||||||
func GetLyricsProviderOrder() []string {
|
func GetLyricsProviderOrder() []string {
|
||||||
lyricsProvidersMu.RLock()
|
lyricsProvidersMu.RLock()
|
||||||
defer lyricsProvidersMu.RUnlock()
|
defer lyricsProvidersMu.RUnlock()
|
||||||
@@ -118,15 +117,14 @@ func GetLyricsProviderOrder() []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
|
||||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||||
return []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": 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": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
{"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 (good for Chinese songs)"},
|
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
|
|
||||||
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||||
normalized := normalizeLyricsFetchOptions(opts)
|
normalized := normalizeLyricsFetchOptions(opts)
|
||||||
|
|
||||||
@@ -155,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
|
|
||||||
func GetLyricsFetchOptions() LyricsFetchOptions {
|
func GetLyricsFetchOptions() LyricsFetchOptions {
|
||||||
lyricsFetchOptionsMu.RLock()
|
lyricsFetchOptionsMu.RLock()
|
||||||
defer lyricsFetchOptionsMu.RUnlock()
|
defer lyricsFetchOptionsMu.RUnlock()
|
||||||
@@ -431,6 +427,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
|||||||
return now.Add(10 * time.Minute)
|
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) {
|
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||||
@@ -449,7 +538,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
|||||||
spotifyID = parsed.ID
|
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)
|
req, err := http.NewRequest("GET", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -462,13 +551,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 != 200 {
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||||
}
|
}
|
||||||
var payload map[string]interface{}
|
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) != "" {
|
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))
|
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||||
}
|
}
|
||||||
@@ -479,63 +573,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
|||||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp SpotifyLyricsAPIResponse
|
return parseSpotifyLyricsResponseBody(bodyBytes)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||||
@@ -624,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||||
|
|
||||||
// Cascade through all configured built-in providers
|
|
||||||
for _, providerName := range providerOrder {
|
for _, providerName := range providerOrder {
|
||||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||||
|
|
||||||
|
|||||||
+65
-126
@@ -4,121 +4,25 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppleMusicClient fetches lyrics from Apple Music.
|
// 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 {
|
type AppleMusicClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apple Music token manager — singleton with mutex for thread safety
|
type appleMusicSearchResult struct {
|
||||||
type appleTokenManager struct {
|
ID string `json:"id"`
|
||||||
mu sync.Mutex
|
SongName string `json:"songName"`
|
||||||
token string
|
ArtistName string `json:"artistName"`
|
||||||
}
|
AlbumName string `json:"albumName"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
// 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.
|
// 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
|
query := trackName + " " + artistName
|
||||||
if strings.TrimSpace(query) == "" {
|
if strings.TrimSpace(query) == "" {
|
||||||
return "", fmt.Errorf("empty search 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)
|
encodedQuery := url.QueryEscape(query)
|
||||||
searchURL := fmt.Sprintf(
|
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||||
"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,
|
|
||||||
)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
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("User-Agent", getRandomUserAgent())
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == 401 {
|
|
||||||
globalAppleTokenManager.clearToken()
|
|
||||||
return "", fmt.Errorf("apple music token expired")
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
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 {
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
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 "", 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.
|
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||||
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
|||||||
durationSec float64,
|
durationSec float64,
|
||||||
multiPersonWordByWord bool,
|
multiPersonWordByWord bool,
|
||||||
) (*LyricsResponse, error) {
|
) (*LyricsResponse, error) {
|
||||||
songID, err := c.SearchSong(trackName, artistName)
|
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
|
|||||||
func NewMusixmatchClient() *MusixmatchClient {
|
func NewMusixmatchClient() *MusixmatchClient {
|
||||||
return &MusixmatchClient{
|
return &MusixmatchClient{
|
||||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
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.
|
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
||||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
|
||||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
|
||||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
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)
|
params := url.Values{}
|
||||||
encodedTrack := url.QueryEscape(trackName)
|
params.Set("t", trackName)
|
||||||
|
params.Set("a", artistName)
|
||||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
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)
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
if err != 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())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
return "", fmt.Errorf("musixmatch request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
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
|
var lrcPayload string
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
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.
|
// 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))
|
lang := strings.ToLower(strings.TrimSpace(language))
|
||||||
if songID <= 0 || lang == "" {
|
if lang == "" {
|
||||||
return nil, fmt.Errorf("invalid song id or language")
|
return nil, fmt.Errorf("invalid language")
|
||||||
}
|
}
|
||||||
|
|
||||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", fullURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result musixmatchSearchResponse
|
lines := parseSyncedLyrics(lrcText)
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if len(lines) > 0 {
|
||||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
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) != "" {
|
plainLines := plainTextLyricsLines(lrcText)
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
if len(plainLines) > 0 {
|
||||||
if len(lines) > 0 {
|
return &LyricsResponse{
|
||||||
return &LyricsResponse{
|
Lines: plainLines,
|
||||||
Lines: lines,
|
SyncType: "UNSYNCED",
|
||||||
SyncType: "LINE_SYNCED",
|
PlainLyrics: lrcText,
|
||||||
Provider: "Musixmatch",
|
Provider: "Musixmatch",
|
||||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||||
}, nil
|
}, 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
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.
|
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||||
if err != nil {
|
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
|
||||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
|
||||||
if localizedErr == nil {
|
if localizedErr == nil {
|
||||||
return localized, nil
|
return localized, nil
|
||||||
}
|
}
|
||||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
if err != nil {
|
||||||
if len(lines) > 0 {
|
return nil, err
|
||||||
return &LyricsResponse{
|
|
||||||
Lines: lines,
|
|
||||||
SyncType: "LINE_SYNCED",
|
|
||||||
Provider: "Musixmatch",
|
|
||||||
Source: "Musixmatch",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
lines := parseSyncedLyrics(lrcText)
|
||||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
if len(lines) > 0 {
|
||||||
|
return &LyricsResponse{
|
||||||
|
Lines: lines,
|
||||||
|
SyncType: "LINE_SYNCED",
|
||||||
|
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||||
|
Provider: "Musixmatch",
|
||||||
|
Source: "Musixmatch",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
if len(lines) > 0 {
|
plainLines := plainTextLyricsLines(lrcText)
|
||||||
return &LyricsResponse{
|
if len(plainLines) > 0 {
|
||||||
Lines: lines,
|
return &LyricsResponse{
|
||||||
SyncType: "UNSYNCED",
|
Lines: plainLines,
|
||||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
SyncType: "UNSYNCED",
|
||||||
Provider: "Musixmatch",
|
PlainLyrics: lrcText,
|
||||||
Source: "Musixmatch",
|
Provider: "Musixmatch",
|
||||||
}, nil
|
Source: "Musixmatch",
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
|
||||||
// This is a direct public API — no proxy dependency.
|
|
||||||
type NeteaseClient struct {
|
type NeteaseClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
|||||||
return 0, fmt.Errorf("empty search query")
|
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 := url.Values{}
|
||||||
params.Set("s", query)
|
params.Set("q", query)
|
||||||
params.Set("type", "1")
|
|
||||||
params.Set("limit", "1")
|
|
||||||
params.Set("offset", "0")
|
|
||||||
|
|
||||||
fullURL := searchURL + "?" + params.Encode()
|
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.
|
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
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 := url.Values{}
|
||||||
params.Set("id", fmt.Sprintf("%d", songID))
|
params.Set("id", fmt.Sprintf("%d", songID))
|
||||||
params.Set("lv", "1")
|
|
||||||
params.Set("tv", "1")
|
|
||||||
params.Set("rv", "1")
|
|
||||||
|
|
||||||
fullURL := lyricsURL + "?" + params.Encode()
|
fullURL := lyricsURL + "?" + params.Encode()
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,31 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// QQMusicClient fetches lyrics from QQ Music.
|
// 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 {
|
type QQMusicClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
type qqMusicSearchResponse struct {
|
type qqLyricsMetadataRequest struct {
|
||||||
Data struct {
|
Artist []string `json:"artist"`
|
||||||
Song struct {
|
Album string `json:"album,omitempty"`
|
||||||
List []struct {
|
SongID int64 `json:"songid,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Singer []struct {
|
Duration int64 `json:"duration,omitempty"`
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"singer"`
|
|
||||||
Album struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"album"`
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
} `json:"list"`
|
|
||||||
} `json:"song"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQ Music lyrics request payload for paxsenix proxy
|
type qqLyricsMetadataResponse struct {
|
||||||
type qqLyricsPayload struct {
|
Lyrics []paxLyrics `json:"lyrics"`
|
||||||
Artist []string `json:"artist"`
|
|
||||||
Album string `json:"album"`
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQMusicClient() *QQMusicClient {
|
func NewQQMusicClient() *QQMusicClient {
|
||||||
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
|
||||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||||
query := trackName + " " + artistName
|
payload := qqLyricsMetadataRequest{
|
||||||
if strings.TrimSpace(query) == "" {
|
Artist: []string{artistName},
|
||||||
return nil, fmt.Errorf("empty search query")
|
Title: trackName,
|
||||||
|
}
|
||||||
|
if durationSec > 0 {
|
||||||
|
payload.Duration = int64(math.Round(durationSec))
|
||||||
}
|
}
|
||||||
|
|
||||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
||||||
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"
|
|
||||||
|
|
||||||
payloadBytes, err := json.Marshal(payload)
|
payloadBytes, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
|
|||||||
return bodyStr, nil
|
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.
|
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||||
func (c *QQMusicClient) FetchLyrics(
|
func (c *QQMusicClient) FetchLyrics(
|
||||||
trackName,
|
trackName,
|
||||||
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
|
|||||||
durationSec float64,
|
durationSec float64,
|
||||||
multiPersonWordByWord bool,
|
multiPersonWordByWord bool,
|
||||||
) (*LyricsResponse, error) {
|
) (*LyricsResponse, error) {
|
||||||
payload, err := c.searchSong(trackName, artistName)
|
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
|
|||||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
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 := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If pax parsing fails, try to use as direct LRC text
|
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||||
lrcText = rawLyrics
|
lrcText = fallback
|
||||||
|
} else {
|
||||||
|
lrcText = rawLyrics
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := parseSyncedLyrics(lrcText)
|
lines := parseSyncedLyrics(lrcText)
|
||||||
|
|||||||
+320
-6
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
return extractLyricsFromSidecarLRC(filePath)
|
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") {
|
if strings.HasSuffix(lower, ".mp3") {
|
||||||
meta, err := ReadID3Tags(filePath)
|
meta, err := ReadID3Tags(filePath)
|
||||||
if err == nil && meta != nil {
|
if err == nil && meta != nil {
|
||||||
@@ -581,6 +589,299 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
return extractLyricsFromSidecarLRC(filePath)
|
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) {
|
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||||
ext := filepath.Ext(filePath)
|
ext := filepath.Ext(filePath)
|
||||||
base := strings.TrimSuffix(filePath, ext)
|
base := strings.TrimSuffix(filePath, ext)
|
||||||
@@ -743,15 +1044,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, err
|
return AudioQuality{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := make([]byte, 24)
|
buf := make([]byte, 32)
|
||||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
// AudioSampleEntry layout from the box type field:
|
||||||
bitDepth := 16
|
// [0:4] type ("mp4a"/"alac")
|
||||||
if atomType == "alac" {
|
// [4:10] SampleEntry.reserved
|
||||||
bitDepth = 24
|
// [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
|
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 {
|
if bestIdx >= 0 {
|
||||||
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
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 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||||
}
|
}
|
||||||
return absolute, bestType, nil
|
return absolute, bestType, nil
|
||||||
|
|||||||
+31
-4
@@ -34,10 +34,16 @@ var (
|
|||||||
downloadDir string
|
downloadDir string
|
||||||
downloadDirMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
|
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
multiMu sync.RWMutex
|
||||||
|
multiProgressDirty = true
|
||||||
|
cachedMultiProgress = "{\"items\":{}}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func markMultiProgressDirtyLocked() {
|
||||||
|
multiProgressDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
@@ -58,13 +64,25 @@ func getProgress() DownloadProgress {
|
|||||||
|
|
||||||
func GetMultiProgress() string {
|
func GetMultiProgress() string {
|
||||||
multiMu.RLock()
|
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)
|
jsonBytes, err := json.Marshal(multiProgress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "{\"items\":{}}"
|
return "{\"items\":{}}"
|
||||||
}
|
}
|
||||||
return string(jsonBytes)
|
cachedMultiProgress = string(jsonBytes)
|
||||||
|
multiProgressDirty = false
|
||||||
|
return cachedMultiProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetItemProgress(itemID string) string {
|
func GetItemProgress(itemID string) string {
|
||||||
@@ -90,6 +108,7 @@ func StartItemProgress(itemID string) {
|
|||||||
IsDownloading: true,
|
IsDownloading: true,
|
||||||
Status: "downloading",
|
Status: "downloading",
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetItemBytesTotal(itemID string, total int64) {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
@@ -98,6 +117,7 @@ func SetItemBytesTotal(itemID string, total int64) {
|
|||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.BytesTotal = total
|
item.BytesTotal = total
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +130,7 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +144,7 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa
|
|||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +156,7 @@ func CompleteItemProgress(itemID string) {
|
|||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.IsDownloading = false
|
item.IsDownloading = false
|
||||||
item.Status = "completed"
|
item.Status = "completed"
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +172,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
if bytesTotal > 0 {
|
if bytesTotal > 0 {
|
||||||
item.BytesTotal = bytesTotal
|
item.BytesTotal = bytesTotal
|
||||||
}
|
}
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +183,7 @@ func SetItemFinalizing(itemID string) {
|
|||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.Status = "finalizing"
|
item.Status = "finalizing"
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +192,7 @@ func RemoveItemProgress(itemID string) {
|
|||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
delete(multiProgress.Items, itemID)
|
delete(multiProgress.Items, itemID)
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func ClearAllItemProgress() {
|
func ClearAllItemProgress() {
|
||||||
@@ -174,6 +200,7 @@ func ClearAllItemProgress() {
|
|||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
multiProgress.Items = make(map[string]*ItemProgress)
|
||||||
|
markMultiProgressDirtyLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
|
|||||||
+927
-50
File diff suppressed because it is too large
Load Diff
+323
-3
@@ -1,6 +1,98 @@
|
|||||||
package gobackend
|
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) {
|
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||||
t.Run("reads top-level download_url and quality metadata", func(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) {
|
func TestQobuzAvailableProviders(t *testing.T) {
|
||||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||||
if len(providers) != 3 {
|
if len(providers) != 5 {
|
||||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||||
}
|
}
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"musicdl": qobuzAPIKindMusicDL,
|
"musicdl": qobuzAPIKindMusicDL,
|
||||||
"dabmusic": qobuzAPIKindStandard,
|
"dabmusic": qobuzAPIKindStandard,
|
||||||
"deeb": qobuzAPIKindStandard,
|
"deeb": qobuzAPIKindStandard,
|
||||||
|
"qbz": qobuzAPIKindStandard,
|
||||||
|
"squid": qobuzAPIKindStandard,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
@@ -133,3 +265,191 @@ func TestQobuzAvailableProviders(t *testing.T) {
|
|||||||
t.Fatalf("missing providers: %v", want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
|
||||||
|
track := &QobuzTrack{
|
||||||
|
Title: "Different Title",
|
||||||
|
Duration: 0,
|
||||||
|
}
|
||||||
|
track.Performer.Name = "Different Artist"
|
||||||
|
|
||||||
|
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
|
||||||
|
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,6 +15,10 @@ type SongLinkClient struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type songLinkPlatformLink struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
type TrackAvailability struct {
|
type TrackAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
@@ -43,6 +48,7 @@ var (
|
|||||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||||
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
}
|
}
|
||||||
|
songLinkRetryConfig = DefaultRetryConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
@@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
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)
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||||
@@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
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()
|
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")
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
}
|
}
|
||||||
if resp.StatusCode == 429 {
|
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 {
|
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)
|
body, err := ReadResponseBody(resp)
|
||||||
@@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
var songLinkResp struct {
|
var songLinkResp struct {
|
||||||
LinksByPlatform map[string]struct {
|
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
availability := &TrackAvailability{
|
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
||||||
SpotifyID: spotifyTrackID,
|
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 != "" {
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||||
availability.Tidal = true
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
availability.TidalURL = tidalLink.URL
|
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
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 != "" {
|
body, err := ReadResponseBody(resp)
|
||||||
availability.Amazon = true
|
if err != nil {
|
||||||
availability.AmazonURL = amazonLink.URL
|
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||||
availability.Deezer = true
|
if err != nil {
|
||||||
availability.DeezerURL = deezerLink.URL
|
return nil, err
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
var pageData struct {
|
||||||
availability.Qobuz = true
|
Props struct {
|
||||||
availability.QobuzURL = qobuzLink.URL
|
PageProps struct {
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
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
|
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||||
availability.YouTube = true
|
for _, link := range section.Links {
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
continue
|
||||||
}
|
}
|
||||||
|
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
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)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
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)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
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)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
@@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
return availability, nil
|
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 {
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
parts := strings.Split(spotifyURL, "/track/")
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
if len(parts) > 1 {
|
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)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
|
||||||
|
|
||||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
|
||||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
|
||||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
|
||||||
parsed, err := parseSpotifyURI(spotifyURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
base := strings.TrimSpace(apiBaseURL)
|
|
||||||
if base == "" {
|
|
||||||
base = DefaultSpotFetchAPIBaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyBytes, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch parsed.Type {
|
|
||||||
case "track":
|
|
||||||
var trackResp TrackResponse
|
|
||||||
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
|
||||||
}
|
|
||||||
return trackResp, nil
|
|
||||||
case "album":
|
|
||||||
var albumResp AlbumResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
|
||||||
}
|
|
||||||
return &albumResp, nil
|
|
||||||
case "playlist":
|
|
||||||
var playlistResp PlaylistResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
|
||||||
}
|
|
||||||
return playlistResp, nil
|
|
||||||
case "artist":
|
|
||||||
var artistResp ArtistResponsePayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
|
||||||
}
|
|
||||||
return &artistResp, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -157,6 +157,8 @@ type AlbumResponsePayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistInfoMetadata struct {
|
type PlaylistInfoMetadata struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Images string `json:"images,omitempty"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
|
|||||||
+1048
-30
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,8 +3,25 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
|
||||||
|
switch r {
|
||||||
|
case 'đ':
|
||||||
|
b.WriteString("dj")
|
||||||
|
case 'ß':
|
||||||
|
b.WriteString("ss")
|
||||||
|
case 'æ':
|
||||||
|
b.WriteString("ae")
|
||||||
|
case 'œ':
|
||||||
|
b.WriteString("oe")
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||||
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
// "Doctor / Cops" and "Doctor _ Cops" can still match.
|
||||||
func normalizeLooseTitle(title string) string {
|
func normalizeLooseTitle(title string) string {
|
||||||
@@ -22,11 +39,39 @@ func normalizeLooseTitle(title string) string {
|
|||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
case unicode.IsSpace(r):
|
case unicode.IsSpace(r):
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
// Treat common separators as spaces.
|
|
||||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
default:
|
default:
|
||||||
// Drop other punctuation/symbols (including emoji) for loose matching.
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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):
|
||||||
|
writeNormalizedArtistRune(&b, r)
|
||||||
|
case unicode.IsSpace(r):
|
||||||
|
b.WriteByte(' ')
|
||||||
|
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||||
|
b.WriteByte(' ')
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,3 +113,51 @@ func normalizeSymbolOnlyTitle(title string) string {
|
|||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||||
|
type resolvedTrackInfo struct {
|
||||||
|
Title string
|
||||||
|
ArtistName string
|
||||||
|
ISRC string
|
||||||
|
Duration int
|
||||||
|
SkipNameVerification bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
exactISRCMatch := req.ISRC != "" &&
|
||||||
|
resolved.ISRC != "" &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
|
||||||
|
|
||||||
|
if !exactISRCMatch && !resolved.SkipNameVerification {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: "Completely Different Title",
|
||||||
|
ArtistName: "Totally Different Artist",
|
||||||
|
SkipNameVerification: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trackMatchesRequest(req, resolved, "test") {
|
||||||
|
t.Fatal("expected SongLink-resolved track to bypass artist/title verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) {
|
||||||
|
req := DownloadRequest{
|
||||||
|
TrackName: "Ringišpil",
|
||||||
|
ArtistName: "Djordje Balasevic",
|
||||||
|
DurationMS: 180000,
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: "Completely Different Title",
|
||||||
|
ArtistName: "Totally Different Artist",
|
||||||
|
Duration: 240,
|
||||||
|
SkipNameVerification: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackMatchesRequest(req, resolved, "test") {
|
||||||
|
t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
func TestTitlesMatch_SeparatorVariants(t *testing.T) {
|
||||||
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
|
||||||
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
|
||||||
|
|||||||
@@ -1,751 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type YouTubeDownloader struct {
|
|
||||||
client *http.Client
|
|
||||||
apiURL string
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
const spotubeBaseURL = "https://spotubedl.com"
|
|
||||||
|
|
||||||
var (
|
|
||||||
globalYouTubeDownloader *YouTubeDownloader
|
|
||||||
youtubeDownloaderOnce sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
type YouTubeQuality string
|
|
||||||
|
|
||||||
const (
|
|
||||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
|
||||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
|
||||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
|
||||||
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
|
||||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
|
||||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
|
||||||
)
|
|
||||||
|
|
||||||
type CobaltRequest struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
|
||||||
AudioFormat string `json:"audioFormat,omitempty"`
|
|
||||||
DownloadMode string `json:"downloadMode,omitempty"`
|
|
||||||
FilenameStyle string `json:"filenameStyle,omitempty"`
|
|
||||||
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CobaltResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
Filename string `json:"filename,omitempty"`
|
|
||||||
Error *struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Context *struct {
|
|
||||||
Service string `json:"service,omitempty"`
|
|
||||||
Limit int `json:"limit,omitempty"`
|
|
||||||
} `json:"context,omitempty"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type YouTubeDownloadResult struct {
|
|
||||||
FilePath string
|
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
ReleaseDate string
|
|
||||||
TrackNumber int
|
|
||||||
DiscNumber int
|
|
||||||
ISRC string
|
|
||||||
Format string // "opus" or "mp3"
|
|
||||||
Bitrate int
|
|
||||||
LyricsLRC string
|
|
||||||
CoverData []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
|
||||||
youtubeDownloaderOnce.Do(func() {
|
|
||||||
globalYouTubeDownloader = &YouTubeDownloader{
|
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
|
||||||
apiURL: "https://api.qwkuns.me",
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return globalYouTubeDownloader
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
|
||||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
|
||||||
return (r < '0' || r > '9')
|
|
||||||
})
|
|
||||||
for i := len(parts) - 1; i >= 0; i-- {
|
|
||||||
part := parts[i]
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if parsed, err := strconv.Atoi(part); err == nil {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultBitrate
|
|
||||||
}
|
|
||||||
|
|
||||||
func nearestSupportedBitrate(value int, supported []int) int {
|
|
||||||
nearest := supported[0]
|
|
||||||
nearestDistance := absInt(value - nearest)
|
|
||||||
|
|
||||||
for _, option := range supported[1:] {
|
|
||||||
distance := absInt(value - option)
|
|
||||||
// On tie prefer higher quality.
|
|
||||||
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
|
||||||
nearest = option
|
|
||||||
nearestDistance = distance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nearest
|
|
||||||
}
|
|
||||||
|
|
||||||
func absInt(value int) int {
|
|
||||||
if value < 0 {
|
|
||||||
return -value
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
|
||||||
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
|
|
||||||
if strings.HasPrefix(normalizedRaw, "opus") {
|
|
||||||
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
|
||||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
|
||||||
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(normalizedRaw, "mp3") {
|
|
||||||
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
|
||||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
|
||||||
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compatibility for legacy symbolic values.
|
|
||||||
switch normalizedRaw {
|
|
||||||
case "opus_256", "opus256", "opus":
|
|
||||||
return "opus", 256, YouTubeQualityOpus256
|
|
||||||
case "opus_128", "opus128":
|
|
||||||
return "opus", 128, YouTubeQualityOpus128
|
|
||||||
case "mp3_320", "mp3320", "mp3", "":
|
|
||||||
return "mp3", 320, YouTubeQualityMP3320
|
|
||||||
case "mp3_256", "mp3256":
|
|
||||||
return "mp3", 256, YouTubeQualityMP3256
|
|
||||||
case "mp3_128", "mp3128":
|
|
||||||
return "mp3", 128, YouTubeQualityMP3128
|
|
||||||
default:
|
|
||||||
return "mp3", 320, YouTubeQualityMP3320
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
|
||||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
|
||||||
searchQuery := url.QueryEscape(query)
|
|
||||||
|
|
||||||
GoLog("[YouTube] Search query: %s\n", query)
|
|
||||||
|
|
||||||
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
|
||||||
|
|
||||||
return youtubeMusicURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
|
||||||
y.mu.Lock()
|
|
||||||
defer y.mu.Unlock()
|
|
||||||
|
|
||||||
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
|
||||||
audioBitrate := strconv.Itoa(bitrate)
|
|
||||||
|
|
||||||
// Try SpotubeDL first (primary)
|
|
||||||
var spotubeErr error
|
|
||||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
|
||||||
if extractErr == nil {
|
|
||||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
|
||||||
videoID, audioFormat, audioBitrate)
|
|
||||||
|
|
||||||
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
|
||||||
if err == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
spotubeErr = err
|
|
||||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: direct Cobalt API (api.qwkuns.me)
|
|
||||||
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
|
||||||
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
|
||||||
cobaltURL, audioFormat, audioBitrate)
|
|
||||||
|
|
||||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
|
||||||
if err != nil {
|
|
||||||
if spotubeErr != nil {
|
|
||||||
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
|
||||||
reqBody := CobaltRequest{
|
|
||||||
URL: videoURL,
|
|
||||||
AudioFormat: audioFormat,
|
|
||||||
AudioBitrate: audioBitrate,
|
|
||||||
DownloadMode: "audio",
|
|
||||||
FilenameStyle: "basic",
|
|
||||||
DisableMetadata: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var cobaltResp CobaltResponse
|
|
||||||
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
|
||||||
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
|
||||||
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cobaltResp.URL == "" {
|
|
||||||
return nil, fmt.Errorf("no download URL in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
|
||||||
return &cobaltResp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
|
||||||
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
|
|
||||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
|
||||||
engines := []string{"v1"}
|
|
||||||
if strings.EqualFold(audioFormat, "mp3") {
|
|
||||||
engines = append(engines, "v3", "v2")
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
|
|
||||||
for _, engine := range engines {
|
|
||||||
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
|
||||||
if err == nil {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastErr == nil {
|
|
||||||
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
|
||||||
}
|
|
||||||
return nil, lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
|
||||||
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
|
||||||
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
|
||||||
|
|
||||||
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", apiURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadURL := strings.TrimSpace(result.URL)
|
|
||||||
if downloadURL == "" {
|
|
||||||
if result.Error != "" {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
|
||||||
}
|
|
||||||
if result.Message != "" {
|
|
||||||
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(downloadURL, "/") {
|
|
||||||
downloadURL = spotubeBaseURL + downloadURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
|
||||||
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := strings.TrimSpace(result.Filename)
|
|
||||||
if filename == "" {
|
|
||||||
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
|
||||||
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
|
||||||
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
|
||||||
filename = decodedFilename
|
|
||||||
} else {
|
|
||||||
filename = queryFilename
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
|
||||||
return &CobaltResponse{
|
|
||||||
Status: "tunnel",
|
|
||||||
URL: downloadURL,
|
|
||||||
Filename: filename,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if itemID != "" {
|
|
||||||
StartItemProgress(itemID)
|
|
||||||
defer CompleteItemProgress(itemID)
|
|
||||||
ctx = initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
|
||||||
if err != nil {
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := openOutputForWrite(outputPath, outputFD)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
|
||||||
|
|
||||||
var written int64
|
|
||||||
if itemID != "" {
|
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
|
||||||
written, err = io.Copy(progressWriter, resp.Body)
|
|
||||||
} else {
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
|
||||||
}
|
|
||||||
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
cleanupOutputOnError(outputPath, outputFD)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
|
||||||
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildYouTubeWatchURL(videoID string) string {
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isYouTubeVideoID(s string) bool {
|
|
||||||
if len(s) != 11 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, c := range s {
|
|
||||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsYouTubeURL(urlStr string) bool {
|
|
||||||
lower := strings.ToLower(urlStr)
|
|
||||||
return strings.Contains(lower, "youtube.com") ||
|
|
||||||
strings.Contains(lower, "youtu.be") ||
|
|
||||||
strings.Contains(lower, "music.youtube.com")
|
|
||||||
}
|
|
||||||
|
|
||||||
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
|
||||||
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
|
||||||
func toYouTubeMusicURL(rawURL string) string {
|
|
||||||
videoID, err := ExtractYouTubeVideoID(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return rawURL
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
|
||||||
if strings.Contains(urlStr, "youtu.be/") {
|
|
||||||
parts := strings.Split(urlStr, "youtu.be/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
videoID := strings.Split(parts[1], "?")[0]
|
|
||||||
videoID = strings.Split(videoID, "&")[0]
|
|
||||||
return strings.TrimSpace(videoID), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(urlStr)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// /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 {
|
|
||||||
return strings.Split(parts[1], "/")[0], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// /v/
|
|
||||||
if strings.Contains(parsed.Path, "/v/") {
|
|
||||||
parts := strings.Split(parsed.Path, "/v/")
|
|
||||||
if len(parts) >= 2 {
|
|
||||||
return strings.Split(parts[1], "/")[0], nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("could not extract video ID from URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
|
|
||||||
// to find a track by artist + title. It filters for tracks only (not videos,
|
|
||||||
// albums, or playlists) and returns the YouTube Music watch URL for the first
|
|
||||||
// matching track, or "" if nothing was found.
|
|
||||||
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
|
|
||||||
extManager := GetExtensionManager()
|
|
||||||
searchProviders := extManager.GetSearchProviders()
|
|
||||||
|
|
||||||
// Find the ytmusic-spotiflac extension
|
|
||||||
var ytProvider *ExtensionProviderWrapper
|
|
||||||
for _, p := range searchProviders {
|
|
||||||
if p.extension.ID == "ytmusic-spotiflac" {
|
|
||||||
ytProvider = p
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ytProvider == nil {
|
|
||||||
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
query := strings.TrimSpace(artistName + " " + trackName)
|
|
||||||
if query == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
|
|
||||||
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
|
|
||||||
"filter": "tracks",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the first track result (item_type == "track" with a valid video ID)
|
|
||||||
for _, track := range results {
|
|
||||||
if track.ItemType != "" && track.ItemType != "track" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
videoID := strings.TrimSpace(track.ID)
|
|
||||||
if videoID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if isYouTubeVideoID(videoID) {
|
|
||||||
return BuildYouTubeWatchURL(videoID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
|
||||||
downloader := NewYouTubeDownloader()
|
|
||||||
|
|
||||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
|
||||||
|
|
||||||
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
|
|
||||||
var youtubeURL string
|
|
||||||
var lookupErr error
|
|
||||||
|
|
||||||
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
|
||||||
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
|
||||||
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
|
||||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try YT Music extension search first (if installed) - more accurate, tracks only
|
|
||||||
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
|
|
||||||
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
|
|
||||||
if youtubeURL != "" {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try Spotify ID via SongLink
|
|
||||||
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
|
||||||
if lookupErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try Deezer ID via SongLink
|
|
||||||
if youtubeURL == "" && req.DeezerID != "" {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
|
||||||
if lookupErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Try ISRC via SongLink
|
|
||||||
if youtubeURL == "" && req.ISRC != "" {
|
|
||||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
|
||||||
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
|
||||||
youtubeURL = availability.YouTubeURL
|
|
||||||
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
|
||||||
} else if isrcErr != nil {
|
|
||||||
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cobalt requires direct video URLs, not search URLs
|
|
||||||
if youtubeURL == "" {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
|
||||||
|
|
||||||
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
|
||||||
if err != nil {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := ".mp3"
|
|
||||||
if format == "opus" {
|
|
||||||
ext = ".opus"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some SpotubeDL engines may return a different output container than requested.
|
|
||||||
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
|
||||||
if cobaltResp != nil && cobaltResp.Filename != "" {
|
|
||||||
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(lowerName, ".mp3"):
|
|
||||||
ext = ".mp3"
|
|
||||||
format = "mp3"
|
|
||||||
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
|
||||||
ext = ".opus"
|
|
||||||
format = "opus"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"date": req.ReleaseDate,
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
})
|
|
||||||
filename = sanitizeFilename(filename) + ext
|
|
||||||
|
|
||||||
var outputPath string
|
|
||||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
|
||||||
if isSafOutput {
|
|
||||||
outputPath = strings.TrimSpace(req.OutputPath)
|
|
||||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
|
||||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outputPath = req.OutputDir + "/" + filename
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
|
||||||
|
|
||||||
var parallelResult *ParallelDownloadResult
|
|
||||||
if req.EmbedLyrics || req.CoverURL != "" {
|
|
||||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
req.CoverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
req.EmbedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
|
||||||
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsLRC := ""
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil {
|
|
||||||
if parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
|
||||||
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
}
|
|
||||||
if parallelResult.CoverData != nil {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return YouTubeDownloadResult{
|
|
||||||
FilePath: outputPath,
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
ReleaseDate: req.ReleaseDate,
|
|
||||||
TrackNumber: req.TrackNumber,
|
|
||||||
DiscNumber: req.DiscNumber,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Format: format,
|
|
||||||
Bitrate: bitrate,
|
|
||||||
LyricsLRC: lyricsLRC,
|
|
||||||
CoverData: coverData,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
|
||||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
|
||||||
if format != "opus" {
|
|
||||||
t.Fatalf("expected opus format, got %s", format)
|
|
||||||
}
|
|
||||||
if bitrate != 128 {
|
|
||||||
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
|
||||||
}
|
|
||||||
if normalized != YouTubeQualityOpus128 {
|
|
||||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
|
||||||
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
|
||||||
if format != "mp3" {
|
|
||||||
t.Fatalf("expected mp3 format, got %s", format)
|
|
||||||
}
|
|
||||||
if bitrate != 256 {
|
|
||||||
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
|
||||||
}
|
|
||||||
if normalized != YouTubeQualityMP3256 {
|
|
||||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
|
||||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
|
||||||
if opusBitrate != 256 {
|
|
||||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
|
||||||
if mp3Bitrate != 128 {
|
|
||||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -153,13 +153,6 @@ import Gobackend // Import Go framework
|
|||||||
var error: NSError?
|
var error: NSError?
|
||||||
|
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "parseSpotifyUrl":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendParseSpotifyURL(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailability":
|
case "checkAvailability":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
@@ -367,6 +360,26 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "getDeezerRelatedArtists":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let artistId = args["artist_id"] as! String
|
let artistId = args["artist_id"] as! String
|
||||||
@@ -383,6 +396,22 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "parseDeezerUrl":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let url = args["url"] as! String
|
let url = args["url"] as! String
|
||||||
@@ -390,6 +419,13 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "parseTidalUrl":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let url = args["url"] as! String
|
let url = args["url"] as! String
|
||||||
@@ -426,13 +462,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
case "getSpotifyMetadataWithFallback":
|
|
||||||
let args = call.arguments as! [String: Any]
|
|
||||||
let url = args["url"] as! String
|
|
||||||
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
|
|
||||||
if let error = error { throw error }
|
|
||||||
return response
|
|
||||||
|
|
||||||
case "checkAvailabilityFromDeezerID":
|
case "checkAvailabilityFromDeezerID":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let deezerTrackId = args["deezer_track_id"] as! String
|
let deezerTrackId = args["deezer_track_id"] as! String
|
||||||
@@ -600,6 +629,20 @@ import Gobackend // Import Go framework
|
|||||||
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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":
|
case "enrichTrackWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -791,6 +834,23 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
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":
|
case "getStoreExtensions":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.7.2';
|
static const String version = '4.1.2';
|
||||||
static const String buildNumber = '105';
|
static const String buildNumber = '119';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
static const String appName = 'SpotiFLAC';
|
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||||
|
|
||||||
|
static const String appName = 'SpotiFLAC Mobile';
|
||||||
static const String copyright = '© 2026 SpotiFLAC';
|
static const String copyright = '© 2026 SpotiFLAC';
|
||||||
|
|
||||||
static const String mobileAuthor = 'zarzet';
|
static const String mobileAuthor = 'zarzet';
|
||||||
static const String originalAuthor = 'afkarxyz';
|
static const String originalAuthor = 'afkarxyz';
|
||||||
|
|
||||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
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 kofiUrl = 'https://ko-fi.com/zarzet';
|
||||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||||
}
|
}
|
||||||
|
|||||||
+1414
-27
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3303
-2147
File diff suppressed because it is too large
Load Diff
@@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
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
|
@override
|
||||||
String get artistAlbums => 'Albums';
|
String get artistAlbums => 'Albums';
|
||||||
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -758,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1197,6 +1230,47 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storeClearFilters => 'Clear filters';
|
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 repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||||
|
|
||||||
@@ -1347,20 +1421,42 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
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
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1458,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1653,6 +1756,25 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'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
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -1829,7 +1951,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2103,6 +2225,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2135,6 +2279,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2388,6 +2544,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2410,4 +2577,708 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
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
|
@override
|
||||||
String get artistAlbums => 'Albums';
|
String get artistAlbums => 'Albums';
|
||||||
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -756,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1195,6 +1228,47 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storeClearFilters => 'Clear filters';
|
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 repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||||
|
|
||||||
@@ -1345,20 +1419,42 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
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
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1456,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1651,6 +1754,25 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'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
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -1827,7 +1949,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2101,6 +2223,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2133,6 +2277,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2386,6 +2542,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2408,4 +2575,708 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -344,7 +344,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
String get aboutAppDescription =>
|
||||||
'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.';
|
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistAlbums => '앨범';
|
String get artistAlbums => '앨범';
|
||||||
@@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => '불러오기';
|
String get dialogImport => '불러오기';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => '취소';
|
String get dialogDiscard => '취소';
|
||||||
|
|
||||||
@@ -738,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => '재생목록들';
|
String get searchPlaylists => '재생목록들';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => '재생';
|
String get tooltipPlay => '재생';
|
||||||
|
|
||||||
@@ -1175,6 +1208,47 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storeClearFilters => 'Clear filters';
|
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 repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||||
|
|
||||||
@@ -1325,20 +1399,42 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
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
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1436,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1631,6 +1734,25 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'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
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -1807,7 +1929,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2081,6 +2203,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2113,6 +2257,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2366,6 +2522,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2388,4 +2555,708 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,16 +158,16 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String optionsConcurrentParallel(int count) {
|
String optionsConcurrentParallel(int count) {
|
||||||
return '$count parallel downloads';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsConcurrentWarning =>
|
String get optionsConcurrentWarning =>
|
||||||
'Parallel downloads may trigger rate limiting';
|
'Parallel downloaden kan leiden tot rate-limiting';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Extension Store';
|
String get optionsExtensionStore => 'Extension Store';
|
||||||
@@ -271,7 +271,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get aboutContributors => 'Contributors';
|
String get aboutContributors => 'Contributors';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
String get aboutMobileDeveloper => '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||||
@@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutAppDescription =>
|
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
|
@override
|
||||||
String get artistAlbums => 'Albums';
|
String get artistAlbums => 'Albums';
|
||||||
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -756,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get searchPlaylists => 'Playlists';
|
String get searchPlaylists => 'Playlists';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitle => 'Sort Results';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDefault => 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortTitleZA => 'Title (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDurationLong => 'Duration (Longest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipPlay => 'Play';
|
String get tooltipPlay => 'Play';
|
||||||
|
|
||||||
@@ -1195,6 +1228,47 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storeClearFilters => 'Clear filters';
|
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 repository';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoExtensions => 'No extensions available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||||
|
|
||||||
@@ -1345,20 +1419,42 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
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
|
@override
|
||||||
String get qualityNote =>
|
String get qualityNote =>
|
||||||
'Actual quality depends on track availability from the service';
|
'Actual quality depends on track availability from the service';
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeQualityNote =>
|
|
||||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadAskBeforeDownload => 'Ask Before Download';
|
String get downloadAskBeforeDownload => 'Ask Before Download';
|
||||||
|
|
||||||
@@ -1456,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||||
'Artist/Album/ and Artist/Singles/';
|
'Artist/Album/ and Artist/Singles/';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderArtistAlbumFlatSubtitle =>
|
||||||
|
'Artist/Album/ and Artist/song.flac';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
String get downloadedAlbumDeleteSelected => 'Delete Selected';
|
||||||
|
|
||||||
@@ -1651,6 +1754,25 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'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
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -1827,7 +1949,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2101,6 +2223,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2133,6 +2277,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2386,6 +2542,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2408,4 +2575,708 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||||
'Artist folders use Track Artist only';
|
'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';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueDownloadingCount(int count) {
|
||||||
|
return 'Downloading ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueDownloadedHeader => 'Downloaded';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueFilteringIndicator => 'Filtering...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueTrackCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueAlbumCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count albums',
|
||||||
|
one: '1 album',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbums => 'No album downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyAlbumsSubtitle =>
|
||||||
|
'Download multiple tracks from an album to see them here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySingles => 'No single downloads';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptySinglesSubtitle =>
|
||||||
|
'Single track downloads will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistory => 'No download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionAllPlaylistsSelected => 'All playlists selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDescription =>
|
||||||
|
'Verify lossless quality with spectrum analysis';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisChannels => 'Channels';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDuration => 'Duration';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisNyquist => 'Nyquist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisFileSize => 'Size';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisPeak => 'Peak';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisRms => 'RMS';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get audioAnalysisSamples => 'Samples';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsSearchWith(String providerName) {
|
||||||
|
return 'Search with $providerName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedDescription =>
|
||||||
|
'Choose which extension provides the home feed on the main screen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAuto => 'Auto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsHomeFeedAutoSubtitle =>
|
||||||
|
'Automatically select the best available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String extensionsHomeFeedUse(String extensionName) {
|
||||||
|
return 'Use $extensionName home feed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaAsc => 'A-Z';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sortAlphaDesc => 'Z-A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadTitle => 'Cancel download?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String cancelDownloadContent(String trackName) {
|
||||||
|
return 'This will cancel the active download for \"$trackName\".';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancelDownloadKeep => 'Keep';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get metadataSaveFailedStorage =>
|
||||||
|
'Failed to write metadata back to storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFolderPickerFailed(String error) {
|
||||||
|
return 'Failed to open folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadAlbum => 'Failed to load album';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get errorLoadArtist => 'Failed to load artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadName => 'Download Progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanName => 'Library Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingTrack(String trackName) {
|
||||||
|
return 'Downloading $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifFinalizingTrack(String trackName) {
|
||||||
|
return 'Finalizing $trackName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifEmbeddingMetadata => 'Embedding metadata...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifAlreadyInLibraryCount(int completed, int total) {
|
||||||
|
return 'Already in Library ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAlreadyInLibrary => 'Already in Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadCompleteCount(int completed, int total) {
|
||||||
|
return 'Download Complete ($completed/$total)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifDownloadComplete => 'Download Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadsFinished(int completed, int failed) {
|
||||||
|
return 'Downloads Finished ($completed done, $failed failed)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifAllDownloadsComplete => 'All Downloads Complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifTracksDownloadedSuccess(int count) {
|
||||||
|
return '$count tracks downloaded successfully';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifScanningLibrary => 'Scanning local library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressWithTotal(
|
||||||
|
int scanned,
|
||||||
|
int total,
|
||||||
|
int percentage,
|
||||||
|
) {
|
||||||
|
return '$scanned/$total files • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
|
||||||
|
return '$scanned files scanned • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanComplete => 'Library scan complete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanCompleteBody(int count) {
|
||||||
|
return '$count tracks indexed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanExcluded(int count) {
|
||||||
|
return '$count excluded';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifLibraryScanErrors(int count) {
|
||||||
|
return '$count errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanFailed => 'Library scan failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanCancelled => 'Library scan cancelled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifLibraryScanStopped => 'Scan stopped before completion.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifDownloadingUpdate(String version) {
|
||||||
|
return 'Downloading SpotiFLAC v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateProgress(String received, String total, int percentage) {
|
||||||
|
return '$received / $total MB • $percentage%';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateReady => 'Update Ready';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String notifUpdateReadyBody(String version) {
|
||||||
|
return 'SpotiFLAC v$version downloaded. Tap to install.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailed => 'Update Failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notifUpdateFailedBody =>
|
||||||
|
'Could not download update. Try again later.';
|
||||||
}
|
}
|
||||||
|
|||||||
+3300
-2144
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1017
-152
File diff suppressed because it is too large
Load Diff
+5661
-4690
File diff suppressed because it is too large
Load Diff
+143
-51
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -555,7 +555,7 @@
|
|||||||
"@setupDownloadLocationTitle": {
|
"@setupDownloadLocationTitle": {
|
||||||
"description": "Download location dialog title"
|
"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": {
|
"@setupDownloadLocationIosMessage": {
|
||||||
"description": "iOS-specific folder info"
|
"description": "iOS-specific folder info"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "Kann {item} nicht lade wegen fehlender Erweiterungsquelle",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -947,7 +959,7 @@
|
|||||||
"@selectionAllSelected": {
|
"@selectionAllSelected": {
|
||||||
"description": "Status - all items selected"
|
"description": "Status - all items selected"
|
||||||
},
|
},
|
||||||
"selectionSelectToDelete": "Titel zum Löschen auswählen",
|
"selectionSelectToDelete": "Titel zum Löschen wählen",
|
||||||
"@selectionSelectToDelete": {
|
"@selectionSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
@@ -975,7 +987,7 @@
|
|||||||
"@searchArtists": {
|
"@searchArtists": {
|
||||||
"description": "Search result category - artists"
|
"description": "Search result category - artists"
|
||||||
},
|
},
|
||||||
"searchAlbums": "Albums",
|
"searchAlbums": "Alben",
|
||||||
"@searchAlbums": {
|
"@searchAlbums": {
|
||||||
"description": "Search result category - albums"
|
"description": "Search result category - albums"
|
||||||
},
|
},
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "Nach Künstler",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1019,7 +1039,7 @@
|
|||||||
"@folderOrganizationDescription": {
|
"@folderOrganizationDescription": {
|
||||||
"description": "Folder organization sheet description"
|
"description": "Folder organization sheet description"
|
||||||
},
|
},
|
||||||
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Verzeichnis",
|
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Ordner",
|
||||||
"@folderOrganizationNoneSubtitle": {
|
"@folderOrganizationNoneSubtitle": {
|
||||||
"description": "Subtitle for no organization option"
|
"description": "Subtitle for no organization option"
|
||||||
},
|
},
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Integriert",
|
"providerBuiltIn": "Integriert",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Erweiterung",
|
"providerExtension": "Erweiterung",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,23 +1773,11 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Qualität vor Download fragen",
|
"downloadAskBeforeDownload": "Qualität vor Download fragen",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
},
|
},
|
||||||
"downloadDirectory": "Downloadverzeichnis",
|
"downloadDirectory": "Download-Ordner",
|
||||||
"@downloadDirectory": {
|
"@downloadDirectory": {
|
||||||
"description": "Setting - download folder"
|
"description": "Setting - download folder"
|
||||||
},
|
},
|
||||||
@@ -1777,15 +1785,15 @@
|
|||||||
"@downloadSeparateSinglesFolder": {
|
"@downloadSeparateSinglesFolder": {
|
||||||
"description": "Setting - separate folder for singles"
|
"description": "Setting - separate folder for singles"
|
||||||
},
|
},
|
||||||
"downloadAlbumFolderStructure": "Album Folder Structure",
|
"downloadAlbumFolderStructure": "Album-Ordnerstruktur",
|
||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"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": {
|
"@downloadUsePrimaryArtistOnly": {
|
||||||
"description": "Setting - strip featured artists from folder name"
|
"description": "Setting - strip featured artists from folder name"
|
||||||
},
|
},
|
||||||
@@ -1793,7 +1801,7 @@
|
|||||||
"@downloadUsePrimaryArtistOnlyEnabled": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"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": {
|
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||||
"description": "Subtitle when primary artist only is disabled"
|
"description": "Subtitle when primary artist only is disabled"
|
||||||
},
|
},
|
||||||
@@ -1821,7 +1829,7 @@
|
|||||||
"@queueClearAllMessage": {
|
"@queueClearAllMessage": {
|
||||||
"description": "Clear queue confirmation"
|
"description": "Clear queue confirmation"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
"settingsAutoExportFailed": "Auto-Export fehlgeschlagener Downloads",
|
||||||
"@settingsAutoExportFailed": {
|
"@settingsAutoExportFailed": {
|
||||||
"description": "Setting toggle for auto-export"
|
"description": "Setting toggle for auto-export"
|
||||||
},
|
},
|
||||||
@@ -1849,15 +1857,15 @@
|
|||||||
"@albumFolderArtistAlbum": {
|
"@albumFolderArtistAlbum": {
|
||||||
"description": "Album folder option"
|
"description": "Album folder option"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/",
|
"albumFolderArtistAlbumSubtitle": "Alben/Künster Name/Album Name/",
|
||||||
"@albumFolderArtistAlbumSubtitle": {
|
"@albumFolderArtistAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistYearAlbum": "Artist / [Year] Album",
|
"albumFolderArtistYearAlbum": "Künstler / [Year] Album",
|
||||||
"@albumFolderArtistYearAlbum": {
|
"@albumFolderArtistYearAlbum": {
|
||||||
"description": "Album folder option with year"
|
"description": "Album folder option with year"
|
||||||
},
|
},
|
||||||
"albumFolderArtistYearAlbumSubtitle": "Albums/Künster Name/[2005] Album Name/",
|
"albumFolderArtistYearAlbumSubtitle": "Alben/Künster Name/[2005] Album Name/",
|
||||||
"@albumFolderArtistYearAlbumSubtitle": {
|
"@albumFolderArtistYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1873,15 +1881,15 @@
|
|||||||
"@albumFolderYearAlbum": {
|
"@albumFolderYearAlbum": {
|
||||||
"description": "Album folder option with year"
|
"description": "Album folder option with year"
|
||||||
},
|
},
|
||||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/",
|
"albumFolderYearAlbumSubtitle": "Alben/[2005] Album Name/",
|
||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
"albumFolderArtistAlbumSingles": "Künstler / Album + Singles",
|
||||||
"@albumFolderArtistAlbumSingles": {
|
"@albumFolderArtistAlbumSingles": {
|
||||||
"description": "Album folder option with singles inside artist"
|
"description": "Album folder option with singles inside artist"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
"albumFolderArtistAlbumSinglesSubtitle": "Künstler/Album/ und Künstler/Singles/",
|
||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1924,7 +1932,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadedAlbumSelectToDelete": "Select tracks to delete",
|
"downloadedAlbumSelectToDelete": "Titel zum Löschen wählen",
|
||||||
"@downloadedAlbumSelectToDelete": {
|
"@downloadedAlbumSelectToDelete": {
|
||||||
"description": "Placeholder when nothing selected"
|
"description": "Placeholder when nothing selected"
|
||||||
},
|
},
|
||||||
@@ -1996,7 +2004,7 @@
|
|||||||
"@discographyAlbumsOnly": {
|
"@discographyAlbumsOnly": {
|
||||||
"description": "Option - download only albums"
|
"description": "Option - download only albums"
|
||||||
},
|
},
|
||||||
"discographyAlbumsOnlySubtitle": "{count} Titel von {albumCount} Albums",
|
"discographyAlbumsOnlySubtitle": "{count} Titel aus {albumCount} Alben",
|
||||||
"@discographyAlbumsOnlySubtitle": {
|
"@discographyAlbumsOnlySubtitle": {
|
||||||
"description": "Subtitle showing album tracks count",
|
"description": "Subtitle showing album tracks count",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2028,7 +2036,7 @@
|
|||||||
"@discographySelectAlbums": {
|
"@discographySelectAlbums": {
|
||||||
"description": "Option - manually select albums to download"
|
"description": "Option - manually select albums to download"
|
||||||
},
|
},
|
||||||
"discographySelectAlbumsSubtitle": "Choose specific albums or singles",
|
"discographySelectAlbumsSubtitle": "Wähle bestimmte Alben oder Singles",
|
||||||
"@discographySelectAlbumsSubtitle": {
|
"@discographySelectAlbumsSubtitle": {
|
||||||
"description": "Subtitle for select albums option"
|
"description": "Subtitle for select albums option"
|
||||||
},
|
},
|
||||||
@@ -2036,7 +2044,7 @@
|
|||||||
"@discographyFetchingTracks": {
|
"@discographyFetchingTracks": {
|
||||||
"description": "Progress - fetching album tracks"
|
"description": "Progress - fetching album tracks"
|
||||||
},
|
},
|
||||||
"discographyFetchingAlbum": "Fetching {current} of {total}...",
|
"discographyFetchingAlbum": "Lade {current} von {total}...",
|
||||||
"@discographyFetchingAlbum": {
|
"@discographyFetchingAlbum": {
|
||||||
"description": "Progress - fetching specific album",
|
"description": "Progress - fetching specific album",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2061,7 +2069,7 @@
|
|||||||
"@discographyDownloadSelected": {
|
"@discographyDownloadSelected": {
|
||||||
"description": "Button - download selected albums"
|
"description": "Button - download selected albums"
|
||||||
},
|
},
|
||||||
"discographyAddedToQueue": "Added {count} tracks to queue",
|
"discographyAddedToQueue": "{count} Titel zur Warteschlange hinzugefügt",
|
||||||
"@discographyAddedToQueue": {
|
"@discographyAddedToQueue": {
|
||||||
"description": "Snackbar - tracks added from discography",
|
"description": "Snackbar - tracks added from discography",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2086,7 +2094,7 @@
|
|||||||
"@discographyNoAlbums": {
|
"@discographyNoAlbums": {
|
||||||
"description": "Error - no albums found for artist"
|
"description": "Error - no albums found for artist"
|
||||||
},
|
},
|
||||||
"discographyFailedToFetch": "Failed to fetch some albums",
|
"discographyFailedToFetch": "Fehler beim Abrufen einiger Alben",
|
||||||
"@discographyFailedToFetch": {
|
"@discographyFailedToFetch": {
|
||||||
"description": "Error - some albums failed to load"
|
"description": "Error - some albums failed to load"
|
||||||
},
|
},
|
||||||
@@ -2098,15 +2106,15 @@
|
|||||||
"@allFilesAccess": {
|
"@allFilesAccess": {
|
||||||
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
|
||||||
},
|
},
|
||||||
"allFilesAccessEnabledSubtitle": "Can write to any folder",
|
"allFilesAccessEnabledSubtitle": "Darf in jeden Ordner schreiben",
|
||||||
"@allFilesAccessEnabledSubtitle": {
|
"@allFilesAccessEnabledSubtitle": {
|
||||||
"description": "Subtitle when all files access is enabled"
|
"description": "Subtitle when all files access is enabled"
|
||||||
},
|
},
|
||||||
"allFilesAccessDisabledSubtitle": "Limited to media folders only",
|
"allFilesAccessDisabledSubtitle": "Nur auf Medienordner begrenzt",
|
||||||
"@allFilesAccessDisabledSubtitle": {
|
"@allFilesAccessDisabledSubtitle": {
|
||||||
"description": "Subtitle when all files access is disabled"
|
"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": {
|
"@allFilesAccessDescription": {
|
||||||
"description": "Description explaining when to enable all files access"
|
"description": "Description explaining when to enable all files access"
|
||||||
},
|
},
|
||||||
@@ -2122,7 +2130,7 @@
|
|||||||
"@settingsLocalLibrary": {
|
"@settingsLocalLibrary": {
|
||||||
"description": "Settings menu item - local library"
|
"description": "Settings menu item - local library"
|
||||||
},
|
},
|
||||||
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
|
"settingsLocalLibrarySubtitle": "Musik scannen & Duplikate erkennen",
|
||||||
"@settingsLocalLibrarySubtitle": {
|
"@settingsLocalLibrarySubtitle": {
|
||||||
"description": "Subtitle for local library settings"
|
"description": "Subtitle for local library settings"
|
||||||
},
|
},
|
||||||
@@ -2130,7 +2138,7 @@
|
|||||||
"@settingsCache": {
|
"@settingsCache": {
|
||||||
"description": "Settings menu item - cache management"
|
"description": "Settings menu item - cache management"
|
||||||
},
|
},
|
||||||
"settingsCacheSubtitle": "View size and clear cached data",
|
"settingsCacheSubtitle": "Größe anzeigen und Daten im Cache leeren",
|
||||||
"@settingsCacheSubtitle": {
|
"@settingsCacheSubtitle": {
|
||||||
"description": "Subtitle for cache management menu"
|
"description": "Subtitle for cache management menu"
|
||||||
},
|
},
|
||||||
@@ -2146,7 +2154,7 @@
|
|||||||
"@libraryEnableLocalLibrary": {
|
"@libraryEnableLocalLibrary": {
|
||||||
"description": "Toggle to enable library scanning"
|
"description": "Toggle to enable library scanning"
|
||||||
},
|
},
|
||||||
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music",
|
"libraryEnableLocalLibrarySubtitle": "Scan und verfolge deine bestehende Musik",
|
||||||
"@libraryEnableLocalLibrarySubtitle": {
|
"@libraryEnableLocalLibrarySubtitle": {
|
||||||
"description": "Subtitle for enable toggle"
|
"description": "Subtitle for enable toggle"
|
||||||
},
|
},
|
||||||
@@ -2158,7 +2166,7 @@
|
|||||||
"@libraryFolderHint": {
|
"@libraryFolderHint": {
|
||||||
"description": "Placeholder when no folder selected"
|
"description": "Placeholder when no folder selected"
|
||||||
},
|
},
|
||||||
"libraryShowDuplicateIndicator": "Show Duplicate Indicator",
|
"libraryShowDuplicateIndicator": "Duplikat Indikator anzeigen",
|
||||||
"@libraryShowDuplicateIndicator": {
|
"@libraryShowDuplicateIndicator": {
|
||||||
"description": "Toggle for duplicate indicator in search"
|
"description": "Toggle for duplicate indicator in search"
|
||||||
},
|
},
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2455,7 +2463,7 @@
|
|||||||
"@tutorialSettingsDesc": {
|
"@tutorialSettingsDesc": {
|
||||||
"description": "Tutorial settings page description"
|
"description": "Tutorial settings page description"
|
||||||
},
|
},
|
||||||
"tutorialSettingsTip1": "Downloadverzeichnis und Ordnerorganisation ändern",
|
"tutorialSettingsTip1": "Download-Ordner und Ordner-Organisation ändern",
|
||||||
"@tutorialSettingsTip1": {
|
"@tutorialSettingsTip1": {
|
||||||
"description": "Tutorial settings tip 1"
|
"description": "Tutorial settings tip 1"
|
||||||
},
|
},
|
||||||
@@ -2529,7 +2537,7 @@
|
|||||||
"@cacheSectionMaintenance": {
|
"@cacheSectionMaintenance": {
|
||||||
"description": "Section header for cleanup actions"
|
"description": "Section header for cleanup actions"
|
||||||
},
|
},
|
||||||
"cacheAppDirectory": "App-Cache Verzeichnis",
|
"cacheAppDirectory": "App-Cache Ordner",
|
||||||
"@cacheAppDirectory": {
|
"@cacheAppDirectory": {
|
||||||
"description": "Cache item title for app cache directory"
|
"description": "Cache item title for app cache directory"
|
||||||
},
|
},
|
||||||
@@ -2537,7 +2545,7 @@
|
|||||||
"@cacheAppDirectoryDesc": {
|
"@cacheAppDirectoryDesc": {
|
||||||
"description": "Description of what app cache directory contains"
|
"description": "Description of what app cache directory contains"
|
||||||
},
|
},
|
||||||
"cacheTempDirectory": "Temporäres Verzeichnis",
|
"cacheTempDirectory": "Temporärer Ordner",
|
||||||
"@cacheTempDirectory": {
|
"@cacheTempDirectory": {
|
||||||
"description": "Cache item title for temporary files directory"
|
"description": "Cache item title for temporary files directory"
|
||||||
},
|
},
|
||||||
@@ -2705,7 +2713,7 @@
|
|||||||
"@trackEditMetadata": {
|
"@trackEditMetadata": {
|
||||||
"description": "Menu action - edit embedded metadata"
|
"description": "Menu action - edit embedded metadata"
|
||||||
},
|
},
|
||||||
"trackCoverSaved": "Cover art saved to {fileName}",
|
"trackCoverSaved": "Cover in {fileName} gespeichert",
|
||||||
"@trackCoverSaved": {
|
"@trackCoverSaved": {
|
||||||
"description": "Snackbar after cover art saved",
|
"description": "Snackbar after cover art saved",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2714,7 +2722,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackCoverNoSource": "No cover art source available",
|
"trackCoverNoSource": "Keine Cover Quelle vorhanden",
|
||||||
"@trackCoverNoSource": {
|
"@trackCoverNoSource": {
|
||||||
"description": "Snackbar when no cover art URL or embedded cover"
|
"description": "Snackbar when no cover art URL or embedded cover"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Erstellen",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
@@ -3094,11 +3186,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Künstlerordner verwenden den Album-Interpreten, wenn verfügbar",
|
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Interpret-Ordner verwenden Album-Interpret, sofern vorhanden",
|
||||||
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
|
||||||
"description": "Subtitle when Album Artist is used for folder naming"
|
"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": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
|
|||||||
+1190
-20
File diff suppressed because it is too large
Load Diff
+401
-7
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "No se puede cargar {item}: falta una fuente de extensión",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -991,10 +1003,26 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"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": "Ninguna organización",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "Por Artista",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1745,10 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2222,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"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": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2358,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2783,6 +2816,367 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "{count} descargado",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3194,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+107
-15
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+107
-15
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+228
-71
@@ -17,7 +17,7 @@
|
|||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Toko",
|
"navStore": "Repo",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
|
"homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
@@ -211,11 +211,11 @@
|
|||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Toko Ekstensi",
|
"optionsExtensionStore": "Repo Ekstensi",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
|
"optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
@@ -318,10 +318,14 @@
|
|||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Toko Ekstensi",
|
"storeTitle": "Repo Ekstensi",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
|
"storeLoadError": "Gagal memuat repo",
|
||||||
|
"@storeLoadError": {
|
||||||
|
"description": "Error heading when the store cannot be loaded"
|
||||||
|
},
|
||||||
"storeSearch": "Cari ekstensi...",
|
"storeSearch": "Cari ekstensi...",
|
||||||
"@storeSearch": {
|
"@storeSearch": {
|
||||||
"description": "Store search placeholder"
|
"description": "Store search placeholder"
|
||||||
@@ -450,7 +454,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -1003,11 +1007,11 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTags": "Show advanced tags",
|
"filenameShowAdvancedTags": "Tampilkan tag lanjutan",
|
||||||
"@filenameShowAdvancedTags": {
|
"@filenameShowAdvancedTags": {
|
||||||
"description": "Toggle label for showing advanced filename tags"
|
"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": {
|
"@filenameShowAdvancedTagsDescription": {
|
||||||
"description": "Description for advanced filename tag toggle"
|
"description": "Description for advanced filename tag toggle"
|
||||||
},
|
},
|
||||||
@@ -1015,6 +1019,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "Berdasarkan Artis",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1109,7 +1121,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Bawaan",
|
"providerBuiltIn": "Bawaan",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Ekstensi",
|
"providerExtension": "Ekstensi",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1209,7 +1221,7 @@
|
|||||||
"@credentialsDescription": {
|
"@credentialsDescription": {
|
||||||
"description": "Credentials dialog explanation"
|
"description": "Credentials dialog explanation"
|
||||||
},
|
},
|
||||||
"credentialsClientId": "Client ID",
|
"credentialsClientId": "ID Klien",
|
||||||
"@credentialsClientId": {
|
"@credentialsClientId": {
|
||||||
"description": "Client ID field label - DO NOT TRANSLATE"
|
"description": "Client ID field label - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
@@ -1217,7 +1229,7 @@
|
|||||||
"@credentialsClientIdHint": {
|
"@credentialsClientIdHint": {
|
||||||
"description": "Client ID placeholder"
|
"description": "Client ID placeholder"
|
||||||
},
|
},
|
||||||
"credentialsClientSecret": "Client Secret",
|
"credentialsClientSecret": "Rahasia Klien",
|
||||||
"@credentialsClientSecret": {
|
"@credentialsClientSecret": {
|
||||||
"description": "Client Secret field label - DO NOT TRANSLATE"
|
"description": "Client Secret field label - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
@@ -1229,7 +1241,7 @@
|
|||||||
"@channelStable": {
|
"@channelStable": {
|
||||||
"description": "Update channel - stable releases"
|
"description": "Update channel - stable releases"
|
||||||
},
|
},
|
||||||
"channelPreview": "Preview",
|
"channelPreview": "Pratinjau",
|
||||||
"@channelPreview": {
|
"@channelPreview": {
|
||||||
"description": "Update channel - beta/preview releases"
|
"description": "Update channel - beta/preview releases"
|
||||||
},
|
},
|
||||||
@@ -1269,39 +1281,39 @@
|
|||||||
"@sectionFileSettings": {
|
"@sectionFileSettings": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
"sectionLyrics": "Lyrics",
|
"sectionLyrics": "Lirik",
|
||||||
"@sectionLyrics": {
|
"@sectionLyrics": {
|
||||||
"description": "Settings section header"
|
"description": "Settings section header"
|
||||||
},
|
},
|
||||||
"lyricsMode": "Lyrics Mode",
|
"lyricsMode": "Mode Lirik",
|
||||||
"@lyricsMode": {
|
"@lyricsMode": {
|
||||||
"description": "Setting - how to save lyrics"
|
"description": "Setting - how to save lyrics"
|
||||||
},
|
},
|
||||||
"lyricsModeDescription": "Choose how lyrics are saved with your downloads",
|
"lyricsModeDescription": "Pilih cara lirik disimpan bersama unduhan Anda",
|
||||||
"@lyricsModeDescription": {
|
"@lyricsModeDescription": {
|
||||||
"description": "Lyrics mode picker description"
|
"description": "Lyrics mode picker description"
|
||||||
},
|
},
|
||||||
"lyricsModeEmbed": "Embed in file",
|
"lyricsModeEmbed": "Sematkan dalam file",
|
||||||
"@lyricsModeEmbed": {
|
"@lyricsModeEmbed": {
|
||||||
"description": "Lyrics mode option - embed in audio file"
|
"description": "Lyrics mode option - embed in audio file"
|
||||||
},
|
},
|
||||||
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata",
|
"lyricsModeEmbedSubtitle": "Lirik tersimpan di dalam metadata FLAC",
|
||||||
"@lyricsModeEmbedSubtitle": {
|
"@lyricsModeEmbedSubtitle": {
|
||||||
"description": "Subtitle for embed option"
|
"description": "Subtitle for embed option"
|
||||||
},
|
},
|
||||||
"lyricsModeExternal": "External .lrc file",
|
"lyricsModeExternal": "File .lrc eksternal",
|
||||||
"@lyricsModeExternal": {
|
"@lyricsModeExternal": {
|
||||||
"description": "Lyrics mode option - separate LRC file"
|
"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": {
|
"@lyricsModeExternalSubtitle": {
|
||||||
"description": "Subtitle for external option"
|
"description": "Subtitle for external option"
|
||||||
},
|
},
|
||||||
"lyricsModeBoth": "Both",
|
"lyricsModeBoth": "Keduanya",
|
||||||
"@lyricsModeBoth": {
|
"@lyricsModeBoth": {
|
||||||
"description": "Lyrics mode option - embed and external"
|
"description": "Lyrics mode option - embed and external"
|
||||||
},
|
},
|
||||||
"lyricsModeBothSubtitle": "Embed and save .lrc file",
|
"lyricsModeBothSubtitle": "Sematkan dan simpan file .lrc",
|
||||||
"@lyricsModeBothSubtitle": {
|
"@lyricsModeBothSubtitle": {
|
||||||
"description": "Subtitle for both option"
|
"description": "Subtitle for both option"
|
||||||
},
|
},
|
||||||
@@ -1447,11 +1459,11 @@
|
|||||||
"@trackGenre": {
|
"@trackGenre": {
|
||||||
"description": "Metadata label - music genre"
|
"description": "Metadata label - music genre"
|
||||||
},
|
},
|
||||||
"trackLabel": "Label",
|
"trackLabel": "Lebel",
|
||||||
"@trackLabel": {
|
"@trackLabel": {
|
||||||
"description": "Metadata label - record label"
|
"description": "Metadata label - record label"
|
||||||
},
|
},
|
||||||
"trackCopyright": "Copyright",
|
"trackCopyright": "Hak cipta",
|
||||||
"@trackCopyright": {
|
"@trackCopyright": {
|
||||||
"description": "Metadata label - copyright information"
|
"description": "Metadata label - copyright information"
|
||||||
},
|
},
|
||||||
@@ -1475,15 +1487,15 @@
|
|||||||
"@trackLyricsLoadFailed": {
|
"@trackLyricsLoadFailed": {
|
||||||
"description": "Message when lyrics loading fails"
|
"description": "Message when lyrics loading fails"
|
||||||
},
|
},
|
||||||
"trackEmbedLyrics": "Embed Lyrics",
|
"trackEmbedLyrics": "Sematkan Lirik",
|
||||||
"@trackEmbedLyrics": {
|
"@trackEmbedLyrics": {
|
||||||
"description": "Action - embed lyrics into audio file"
|
"description": "Action - embed lyrics into audio file"
|
||||||
},
|
},
|
||||||
"trackLyricsEmbedded": "Lyrics embedded successfully",
|
"trackLyricsEmbedded": "Lirik berhasil disematkan",
|
||||||
"@trackLyricsEmbedded": {
|
"@trackLyricsEmbedded": {
|
||||||
"description": "Snackbar - lyrics saved to file"
|
"description": "Snackbar - lyrics saved to file"
|
||||||
},
|
},
|
||||||
"trackInstrumental": "Instrumental track",
|
"trackInstrumental": "Lagu instrumental",
|
||||||
"@trackInstrumental": {
|
"@trackInstrumental": {
|
||||||
"description": "Message when track is instrumental (no lyrics)"
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
},
|
},
|
||||||
@@ -1562,7 +1574,7 @@
|
|||||||
"@storeClearFilters": {
|
"@storeClearFilters": {
|
||||||
"description": "Button to clear all filters"
|
"description": "Button to clear all filters"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
"extensionDefaultProvider": "Bawaan (Deezer/Spotify)",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
@@ -1578,7 +1590,7 @@
|
|||||||
"@extensionId": {
|
"@extensionId": {
|
||||||
"description": "Extension detail - unique ID"
|
"description": "Extension detail - unique ID"
|
||||||
},
|
},
|
||||||
"extensionError": "Error",
|
"extensionError": "Terjadi kesalahan",
|
||||||
"@extensionError": {
|
"@extensionError": {
|
||||||
"description": "Extension detail - error message"
|
"description": "Extension detail - error message"
|
||||||
},
|
},
|
||||||
@@ -1765,18 +1777,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -1793,19 +1793,35 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"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": {
|
"@downloadUsePrimaryArtistOnly": {
|
||||||
"description": "Setting - strip featured artists from folder name"
|
"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": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"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": {
|
"@downloadUsePrimaryArtistOnlyDisabled": {
|
||||||
"description": "Subtitle when primary artist only is disabled"
|
"description": "Subtitle when primary artist only is disabled"
|
||||||
},
|
},
|
||||||
@@ -1833,27 +1849,27 @@
|
|||||||
"@queueClearAllMessage": {
|
"@queueClearAllMessage": {
|
||||||
"description": "Clear queue confirmation"
|
"description": "Clear queue confirmation"
|
||||||
},
|
},
|
||||||
"settingsAutoExportFailed": "Auto-export failed downloads",
|
"settingsAutoExportFailed": "Unduhan yang gagal diekspor otomatis",
|
||||||
"@settingsAutoExportFailed": {
|
"@settingsAutoExportFailed": {
|
||||||
"description": "Setting toggle for auto-export"
|
"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": {
|
"@settingsAutoExportFailedSubtitle": {
|
||||||
"description": "Subtitle for auto-export setting"
|
"description": "Subtitle for auto-export setting"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetwork": "Download Network",
|
"settingsDownloadNetwork": "Jaringan Unduhan",
|
||||||
"@settingsDownloadNetwork": {
|
"@settingsDownloadNetwork": {
|
||||||
"description": "Setting for network type preference"
|
"description": "Setting for network type preference"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkAny": "WiFi + Mobile Data",
|
"settingsDownloadNetworkAny": "WiFi + Data Seluler",
|
||||||
"@settingsDownloadNetworkAny": {
|
"@settingsDownloadNetworkAny": {
|
||||||
"description": "Network option - use any connection"
|
"description": "Network option - use any connection"
|
||||||
},
|
},
|
||||||
"settingsDownloadNetworkWifiOnly": "WiFi Only",
|
"settingsDownloadNetworkWifiOnly": "Hanya WiFi",
|
||||||
"@settingsDownloadNetworkWifiOnly": {
|
"@settingsDownloadNetworkWifiOnly": {
|
||||||
"description": "Network option - only use WiFi"
|
"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": {
|
"@settingsDownloadNetworkSubtitle": {
|
||||||
"description": "Subtitle explaining network preference"
|
"description": "Subtitle explaining network preference"
|
||||||
},
|
},
|
||||||
@@ -1889,11 +1905,11 @@
|
|||||||
"@albumFolderYearAlbumSubtitle": {
|
"@albumFolderYearAlbumSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSingles": "Artist / Album + Singles",
|
"albumFolderArtistAlbumSingles": "Artis / Album + Singel",
|
||||||
"@albumFolderArtistAlbumSingles": {
|
"@albumFolderArtistAlbumSingles": {
|
||||||
"description": "Album folder option with singles inside artist"
|
"description": "Album folder option with singles inside artist"
|
||||||
},
|
},
|
||||||
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/",
|
"albumFolderArtistAlbumSinglesSubtitle": "Artis/Album/ dan Artis/Single/",
|
||||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||||
"description": "Folder structure example"
|
"description": "Folder structure example"
|
||||||
},
|
},
|
||||||
@@ -1962,19 +1978,19 @@
|
|||||||
"@recentTypeSong": {
|
"@recentTypeSong": {
|
||||||
"description": "Recent access item type - song/track"
|
"description": "Recent access item type - song/track"
|
||||||
},
|
},
|
||||||
"recentTypePlaylist": "Playlist",
|
"recentTypePlaylist": "Daftar putar",
|
||||||
"@recentTypePlaylist": {
|
"@recentTypePlaylist": {
|
||||||
"description": "Recent access item type - playlist"
|
"description": "Recent access item type - playlist"
|
||||||
},
|
},
|
||||||
"recentEmpty": "No recent items yet",
|
"recentEmpty": "Belum ada item terbaru",
|
||||||
"@recentEmpty": {
|
"@recentEmpty": {
|
||||||
"description": "Empty state text for recent access list"
|
"description": "Empty state text for recent access list"
|
||||||
},
|
},
|
||||||
"recentShowAllDownloads": "Show All Downloads",
|
"recentShowAllDownloads": "Tampilkan Semua Unduhan",
|
||||||
"@recentShowAllDownloads": {
|
"@recentShowAllDownloads": {
|
||||||
"description": "Button label to unhide hidden downloads in recent access"
|
"description": "Button label to unhide hidden downloads in recent access"
|
||||||
},
|
},
|
||||||
"recentPlaylistInfo": "Playlist: {name}",
|
"recentPlaylistInfo": "Daftar Putar: {name}",
|
||||||
"@recentPlaylistInfo": {
|
"@recentPlaylistInfo": {
|
||||||
"description": "Snackbar message when tapping playlist in recent access",
|
"description": "Snackbar message when tapping playlist in recent access",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -1984,7 +2000,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discographyDownload": "Download Discography",
|
"discographyDownload": "Unduh Diskografi",
|
||||||
"@discographyDownload": {
|
"@discographyDownload": {
|
||||||
"description": "Button - download artist discography"
|
"description": "Button - download artist discography"
|
||||||
},
|
},
|
||||||
@@ -2383,47 +2399,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
|
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
|
||||||
"@tutorialWelcomeTitle": {
|
"@tutorialWelcomeTitle": {
|
||||||
"description": "Tutorial welcome page title"
|
"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": {
|
"@tutorialWelcomeDesc": {
|
||||||
"description": "Tutorial welcome page description"
|
"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": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
|
"tutorialWelcomeTip3": "Penyematan metadata, sampul album, dan lirik secara otomatis",
|
||||||
"@tutorialWelcomeTip3": {
|
"@tutorialWelcomeTip3": {
|
||||||
"description": "Tutorial welcome tip 3"
|
"description": "Tutorial welcome tip 3"
|
||||||
},
|
},
|
||||||
"tutorialSearchTitle": "Finding Music",
|
"tutorialSearchTitle": "Menemukan Musik",
|
||||||
"@tutorialSearchTitle": {
|
"@tutorialSearchTitle": {
|
||||||
"description": "Tutorial search page title"
|
"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": {
|
"@tutorialSearchDesc": {
|
||||||
"description": "Tutorial search page description"
|
"description": "Tutorial search page description"
|
||||||
},
|
},
|
||||||
"tutorialDownloadTitle": "Downloading Music",
|
"tutorialDownloadTitle": "Mengunduh Musik",
|
||||||
"@tutorialDownloadTitle": {
|
"@tutorialDownloadTitle": {
|
||||||
"description": "Tutorial download page title"
|
"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": {
|
"@tutorialDownloadDesc": {
|
||||||
"description": "Tutorial download page description"
|
"description": "Tutorial download page description"
|
||||||
},
|
},
|
||||||
"tutorialLibraryTitle": "Your Library",
|
"tutorialLibraryTitle": "Perpustakaan Anda",
|
||||||
"@tutorialLibraryTitle": {
|
"@tutorialLibraryTitle": {
|
||||||
"description": "Tutorial library page title"
|
"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": {
|
"@tutorialLibraryDesc": {
|
||||||
"description": "Tutorial library page description"
|
"description": "Tutorial library page description"
|
||||||
},
|
},
|
||||||
@@ -2447,7 +2463,7 @@
|
|||||||
"@tutorialExtensionsDesc": {
|
"@tutorialExtensionsDesc": {
|
||||||
"description": "Tutorial extensions page description"
|
"description": "Tutorial extensions page description"
|
||||||
},
|
},
|
||||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
"tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
|
||||||
"@tutorialExtensionsTip1": {
|
"@tutorialExtensionsTip1": {
|
||||||
"description": "Tutorial extensions tip 1"
|
"description": "Tutorial extensions tip 1"
|
||||||
},
|
},
|
||||||
@@ -2755,6 +2771,47 @@
|
|||||||
"@trackReEnrichFfmpegFailed": {
|
"@trackReEnrichFfmpegFailed": {
|
||||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
"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": "Failed: {error}",
|
||||||
"@trackSaveFailed": {
|
"@trackSaveFailed": {
|
||||||
"description": "Snackbar when save operation fails",
|
"description": "Snackbar when save operation fails",
|
||||||
@@ -2768,7 +2825,7 @@
|
|||||||
"@trackConvertFormat": {
|
"@trackConvertFormat": {
|
||||||
"description": "Menu item - convert audio format"
|
"description": "Menu item - convert audio format"
|
||||||
},
|
},
|
||||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
|
||||||
"@trackConvertFormatSubtitle": {
|
"@trackConvertFormatSubtitle": {
|
||||||
"description": "Subtitle for convert format menu item"
|
"description": "Subtitle for convert format menu item"
|
||||||
},
|
},
|
||||||
@@ -2803,6 +2860,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": "Converting audio...",
|
||||||
"@trackConvertConverting": {
|
"@trackConvertConverting": {
|
||||||
"description": "Snackbar while converting"
|
"description": "Snackbar while converting"
|
||||||
@@ -2820,6 +2893,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
@@ -3114,4 +3271,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-24
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "{item} を読み込めません: 拡張ソースがありません",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -991,7 +1003,7 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"description": "Setting title - filename pattern"
|
||||||
},
|
},
|
||||||
"filenameShowAdvancedTags": "Show advanced tags",
|
"filenameShowAdvancedTags": "高度なタグを表示",
|
||||||
"@filenameShowAdvancedTags": {
|
"@filenameShowAdvancedTags": {
|
||||||
"description": "Toggle label for showing advanced filename tags"
|
"description": "Toggle label for showing advanced filename tags"
|
||||||
},
|
},
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "アーティスト別",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "内蔵",
|
"providerBuiltIn": "内蔵",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "拡張",
|
"providerExtension": "拡張",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1471,7 +1491,7 @@
|
|||||||
"@trackLyricsEmbedded": {
|
"@trackLyricsEmbedded": {
|
||||||
"description": "Snackbar - lyrics saved to file"
|
"description": "Snackbar - lyrics saved to file"
|
||||||
},
|
},
|
||||||
"trackInstrumental": "Instrumental track",
|
"trackInstrumental": "インストゥルメンタルのトラック",
|
||||||
"@trackInstrumental": {
|
"@trackInstrumental": {
|
||||||
"description": "Message when track is instrumental (no lyrics)"
|
"description": "Message when track is instrumental (no lyrics)"
|
||||||
},
|
},
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "ダウンロード前に確認する",
|
"downloadAskBeforeDownload": "ダウンロード前に確認する",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
@@ -2940,7 +3032,7 @@
|
|||||||
"@collectionRemoveFromPlaylist": {
|
"@collectionRemoveFromPlaylist": {
|
||||||
"description": "Tooltip for removing track from playlist"
|
"description": "Tooltip for removing track from playlist"
|
||||||
},
|
},
|
||||||
"collectionRemoveFromFolder": "Remove from folder",
|
"collectionRemoveFromFolder": "フォルダから削除",
|
||||||
"@collectionRemoveFromFolder": {
|
"@collectionRemoveFromFolder": {
|
||||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||||
},
|
},
|
||||||
@@ -2997,23 +3089,23 @@
|
|||||||
"@trackOptionRemoveFromLoved": {
|
"@trackOptionRemoveFromLoved": {
|
||||||
"description": "Bottom sheet action label - remove track from loved folder"
|
"description": "Bottom sheet action label - remove track from loved folder"
|
||||||
},
|
},
|
||||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
"trackOptionAddToWishlist": "ウィッシュリストに追加",
|
||||||
"@trackOptionAddToWishlist": {
|
"@trackOptionAddToWishlist": {
|
||||||
"description": "Bottom sheet action label - add track to wishlist"
|
"description": "Bottom sheet action label - add track to wishlist"
|
||||||
},
|
},
|
||||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
"trackOptionRemoveFromWishlist": "ウィッシュから削除",
|
||||||
"@trackOptionRemoveFromWishlist": {
|
"@trackOptionRemoveFromWishlist": {
|
||||||
"description": "Bottom sheet action label - remove track from wishlist"
|
"description": "Bottom sheet action label - remove track from wishlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistChangeCover": "Change cover image",
|
"collectionPlaylistChangeCover": "カバー画像を変更",
|
||||||
"@collectionPlaylistChangeCover": {
|
"@collectionPlaylistChangeCover": {
|
||||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||||
},
|
},
|
||||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
"collectionPlaylistRemoveCover": "カバー画像を削除",
|
||||||
"@collectionPlaylistRemoveCover": {
|
"@collectionPlaylistRemoveCover": {
|
||||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
"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": {
|
"@selectionShareCount": {
|
||||||
"description": "Share button text with count in selection mode",
|
"description": "Share button text with count in selection mode",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -3039,7 +3131,7 @@
|
|||||||
"@selectionConvertNoConvertible": {
|
"@selectionConvertNoConvertible": {
|
||||||
"description": "Snackbar when no selected tracks support conversion"
|
"description": "Snackbar when no selected tracks support conversion"
|
||||||
},
|
},
|
||||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
"selectionBatchConvertConfirmTitle": "一括変換",
|
||||||
"@selectionBatchConvertConfirmTitle": {
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
"description": "Confirmation dialog title for batch conversion"
|
"description": "Confirmation dialog title for batch conversion"
|
||||||
},
|
},
|
||||||
|
|||||||
+107
-15
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.",
|
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+111
-19
@@ -194,11 +194,11 @@
|
|||||||
"@optionsConcurrentDownloads": {
|
"@optionsConcurrentDownloads": {
|
||||||
"description": "Number of parallel downloads"
|
"description": "Number of parallel downloads"
|
||||||
},
|
},
|
||||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
"optionsConcurrentSequential": "Sequentiële (1 per keer)",
|
||||||
"@optionsConcurrentSequential": {
|
"@optionsConcurrentSequential": {
|
||||||
"description": "Download one at a time"
|
"description": "Download one at a time"
|
||||||
},
|
},
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
"optionsConcurrentParallel": "",
|
||||||
"@optionsConcurrentParallel": {
|
"@optionsConcurrentParallel": {
|
||||||
"description": "Multiple parallel downloads",
|
"description": "Multiple parallel downloads",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
"optionsConcurrentWarning": "Parallel downloaden kan leiden tot rate-limiting",
|
||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
@@ -346,7 +346,7 @@
|
|||||||
"@aboutContributors": {
|
"@aboutContributors": {
|
||||||
"description": "Section for contributors"
|
"description": "Section for contributors"
|
||||||
},
|
},
|
||||||
"aboutMobileDeveloper": "Mobile version developer",
|
"aboutMobileDeveloper": "",
|
||||||
"@aboutMobileDeveloper": {
|
"@aboutMobileDeveloper": {
|
||||||
"description": "Role description for mobile dev"
|
"description": "Role description for mobile dev"
|
||||||
},
|
},
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+401
-7
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "Não é possível carregar {item}: faltando a fonte da extensão",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -991,10 +1003,26 @@
|
|||||||
"@filenameFormat": {
|
"@filenameFormat": {
|
||||||
"description": "Setting title - filename pattern"
|
"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": "Nenhuma organização",
|
||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "Por Artista",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1745,10 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2198,6 +2222,15 @@
|
|||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"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": "Last scanned: {time}",
|
||||||
"@libraryLastScanned": {
|
"@libraryLastScanned": {
|
||||||
"description": "Last scan time display",
|
"description": "Last scan time display",
|
||||||
@@ -2358,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2783,6 +2816,367 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "{count} baixado(s)",
|
||||||
"@downloadedAlbumDownloadedCount": {
|
"@downloadedAlbumDownloadedCount": {
|
||||||
"description": "Downloaded tracks count badge",
|
"description": "Downloaded tracks count badge",
|
||||||
@@ -2800,4 +3194,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+114
-22
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"description": "Credit for SpotiSaver API"
|
||||||
},
|
},
|
||||||
"aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.",
|
"aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
|
||||||
"@aboutAppDescription": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "Невозможно загрузить {item}: отсутствует источник расширения",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"description": "Folder option - flat structure"
|
||||||
},
|
},
|
||||||
|
"folderOrganizationByPlaylist": "По плейлисту",
|
||||||
|
"@folderOrganizationByPlaylist": {
|
||||||
|
"description": "Folder option - playlist folders"
|
||||||
|
},
|
||||||
|
"folderOrganizationByPlaylistSubtitle": "Отдельная папка для каждого плейлиста",
|
||||||
|
"@folderOrganizationByPlaylistSubtitle": {
|
||||||
|
"description": "Subtitle for playlist folder option"
|
||||||
|
},
|
||||||
"folderOrganizationByArtist": "По исполнителю",
|
"folderOrganizationByArtist": "По исполнителю",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Встроенные",
|
"providerBuiltIn": "Встроенные",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Расширение",
|
"providerExtension": "Расширение",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
|
"downloadAskBeforeDownload": "Спрашивать перед скачиванием",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -1789,7 +1797,7 @@
|
|||||||
"@downloadUsePrimaryArtistOnly": {
|
"@downloadUsePrimaryArtistOnly": {
|
||||||
"description": "Setting - strip featured artists from folder name"
|
"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": {
|
"@downloadUsePrimaryArtistOnlyEnabled": {
|
||||||
"description": "Subtitle when primary artist only is enabled"
|
"description": "Subtitle when primary artist only is enabled"
|
||||||
},
|
},
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Amazon Music",
|
"tutorialWelcomeTip2": "Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2487,7 +2495,7 @@
|
|||||||
"@cleanupOrphanedDownloadsSubtitle": {
|
"@cleanupOrphanedDownloadsSubtitle": {
|
||||||
"description": "Subtitle for orphaned cleanup button"
|
"description": "Subtitle for orphaned cleanup button"
|
||||||
},
|
},
|
||||||
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
|
"cleanupOrphanedDownloadsResult": "Удалено {count} утерянных записей из истории",
|
||||||
"@cleanupOrphanedDownloadsResult": {
|
"@cleanupOrphanedDownloadsResult": {
|
||||||
"description": "Snackbar after orphan cleanup",
|
"description": "Snackbar after orphan cleanup",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -2525,7 +2533,7 @@
|
|||||||
"@cacheSectionStorage": {
|
"@cacheSectionStorage": {
|
||||||
"description": "Section header for cache entries"
|
"description": "Section header for cache entries"
|
||||||
},
|
},
|
||||||
"cacheSectionMaintenance": "Maintenance",
|
"cacheSectionMaintenance": "Обслуживание",
|
||||||
"@cacheSectionMaintenance": {
|
"@cacheSectionMaintenance": {
|
||||||
"description": "Section header for cleanup actions"
|
"description": "Section header for cleanup actions"
|
||||||
},
|
},
|
||||||
@@ -2577,7 +2585,7 @@
|
|||||||
"@cacheTrackLookupDesc": {
|
"@cacheTrackLookupDesc": {
|
||||||
"description": "Description of what track lookup cache contains"
|
"description": "Description of what track lookup cache contains"
|
||||||
},
|
},
|
||||||
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
|
"cacheCleanupUnusedDesc": "Удалить записи из истории загрузок и библиотеки, которые остались без файлов.",
|
||||||
"@cacheCleanupUnusedDesc": {
|
"@cacheCleanupUnusedDesc": {
|
||||||
"description": "Description of what cleanup unused data does"
|
"description": "Description of what cleanup unused data does"
|
||||||
},
|
},
|
||||||
@@ -2653,7 +2661,7 @@
|
|||||||
"@cacheCleanupUnused": {
|
"@cacheCleanupUnused": {
|
||||||
"description": "Action title for cleaning unused entries"
|
"description": "Action title for cleaning unused entries"
|
||||||
},
|
},
|
||||||
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
|
"cacheCleanupUnusedSubtitle": "Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке",
|
||||||
"@cacheCleanupUnusedSubtitle": {
|
"@cacheCleanupUnusedSubtitle": {
|
||||||
"description": "Subtitle for cleanup unused data action"
|
"description": "Subtitle for cleanup unused data action"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Создать",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
@@ -3022,7 +3114,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selectionShareNoFiles": "No shareable files found",
|
"selectionShareNoFiles": "Файлы, доступные для совместного доступа, не найдены",
|
||||||
"@selectionShareNoFiles": {
|
"@selectionShareNoFiles": {
|
||||||
"description": "Snackbar when no selected files exist on disk"
|
"description": "Snackbar when no selected files exist on disk"
|
||||||
},
|
},
|
||||||
@@ -3043,7 +3135,7 @@
|
|||||||
"@selectionBatchConvertConfirmTitle": {
|
"@selectionBatchConvertConfirmTitle": {
|
||||||
"description": "Confirmation dialog title for batch conversion"
|
"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": {
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
"description": "Confirmation dialog message for batch conversion",
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
+536
-142
File diff suppressed because it is too large
Load Diff
+203
-111
@@ -5,143 +5,143 @@
|
|||||||
"@appName": {
|
"@appName": {
|
||||||
"description": "App name - DO NOT TRANSLATE"
|
"description": "App name - DO NOT TRANSLATE"
|
||||||
},
|
},
|
||||||
"navHome": "Home",
|
"navHome": "主页",
|
||||||
"@navHome": {
|
"@navHome": {
|
||||||
"description": "Bottom navigation - Home tab"
|
"description": "Bottom navigation - Home tab"
|
||||||
},
|
},
|
||||||
"navLibrary": "Library",
|
"navLibrary": "乐库",
|
||||||
"@navLibrary": {
|
"@navLibrary": {
|
||||||
"description": "Bottom navigation - Library tab"
|
"description": "Bottom navigation - Library tab"
|
||||||
},
|
},
|
||||||
"navSettings": "Settings",
|
"navSettings": "设置",
|
||||||
"@navSettings": {
|
"@navSettings": {
|
||||||
"description": "Bottom navigation - Settings tab"
|
"description": "Bottom navigation - Settings tab"
|
||||||
},
|
},
|
||||||
"navStore": "Store",
|
"navStore": "商店",
|
||||||
"@navStore": {
|
"@navStore": {
|
||||||
"description": "Bottom navigation - Extension store tab"
|
"description": "Bottom navigation - Extension store tab"
|
||||||
},
|
},
|
||||||
"homeTitle": "Home",
|
"homeTitle": "主页",
|
||||||
"@homeTitle": {
|
"@homeTitle": {
|
||||||
"description": "Home screen title"
|
"description": "Home screen title"
|
||||||
},
|
},
|
||||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
"homeSubtitle": "粘贴 Spotify 链接或按名称搜索",
|
||||||
"@homeSubtitle": {
|
"@homeSubtitle": {
|
||||||
"description": "Subtitle shown below search box"
|
"description": "Subtitle shown below search box"
|
||||||
},
|
},
|
||||||
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs",
|
"homeSupports": "支持:歌曲、专辑、播放列表、艺人网址",
|
||||||
"@homeSupports": {
|
"@homeSupports": {
|
||||||
"description": "Info text about supported URL types"
|
"description": "Info text about supported URL types"
|
||||||
},
|
},
|
||||||
"homeRecent": "Recent",
|
"homeRecent": "最近",
|
||||||
"@homeRecent": {
|
"@homeRecent": {
|
||||||
"description": "Section header for recent searches"
|
"description": "Section header for recent searches"
|
||||||
},
|
},
|
||||||
"historyFilterAll": "All",
|
"historyFilterAll": "全部",
|
||||||
"@historyFilterAll": {
|
"@historyFilterAll": {
|
||||||
"description": "Filter chip - show all items"
|
"description": "Filter chip - show all items"
|
||||||
},
|
},
|
||||||
"historyFilterAlbums": "Albums",
|
"historyFilterAlbums": "专辑",
|
||||||
"@historyFilterAlbums": {
|
"@historyFilterAlbums": {
|
||||||
"description": "Filter chip - show albums only"
|
"description": "Filter chip - show albums only"
|
||||||
},
|
},
|
||||||
"historyFilterSingles": "Singles",
|
"historyFilterSingles": "单曲",
|
||||||
"@historyFilterSingles": {
|
"@historyFilterSingles": {
|
||||||
"description": "Filter chip - show singles only"
|
"description": "Filter chip - show singles only"
|
||||||
},
|
},
|
||||||
"historySearchHint": "Search history...",
|
"historySearchHint": "搜索历史……",
|
||||||
"@historySearchHint": {
|
"@historySearchHint": {
|
||||||
"description": "Search bar placeholder in history"
|
"description": "Search bar placeholder in history"
|
||||||
},
|
},
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "设置",
|
||||||
"@settingsTitle": {
|
"@settingsTitle": {
|
||||||
"description": "Settings screen title"
|
"description": "Settings screen title"
|
||||||
},
|
},
|
||||||
"settingsDownload": "Download",
|
"settingsDownload": "下载",
|
||||||
"@settingsDownload": {
|
"@settingsDownload": {
|
||||||
"description": "Settings section - download options"
|
"description": "Settings section - download options"
|
||||||
},
|
},
|
||||||
"settingsAppearance": "Appearance",
|
"settingsAppearance": "外观",
|
||||||
"@settingsAppearance": {
|
"@settingsAppearance": {
|
||||||
"description": "Settings section - visual customization"
|
"description": "Settings section - visual customization"
|
||||||
},
|
},
|
||||||
"settingsOptions": "Options",
|
"settingsOptions": "选项",
|
||||||
"@settingsOptions": {
|
"@settingsOptions": {
|
||||||
"description": "Settings section - app options"
|
"description": "Settings section - app options"
|
||||||
},
|
},
|
||||||
"settingsExtensions": "Extensions",
|
"settingsExtensions": "扩展",
|
||||||
"@settingsExtensions": {
|
"@settingsExtensions": {
|
||||||
"description": "Settings section - extension management"
|
"description": "Settings section - extension management"
|
||||||
},
|
},
|
||||||
"settingsAbout": "About",
|
"settingsAbout": "关于",
|
||||||
"@settingsAbout": {
|
"@settingsAbout": {
|
||||||
"description": "Settings section - app info"
|
"description": "Settings section - app info"
|
||||||
},
|
},
|
||||||
"downloadTitle": "Download",
|
"downloadTitle": "下载",
|
||||||
"@downloadTitle": {
|
"@downloadTitle": {
|
||||||
"description": "Download settings page title"
|
"description": "Download settings page title"
|
||||||
},
|
},
|
||||||
"downloadAskQualitySubtitle": "Show quality picker for each download",
|
"downloadAskQualitySubtitle": "为每次下载显示质量选择器",
|
||||||
"@downloadAskQualitySubtitle": {
|
"@downloadAskQualitySubtitle": {
|
||||||
"description": "Subtitle for ask quality toggle"
|
"description": "Subtitle for ask quality toggle"
|
||||||
},
|
},
|
||||||
"downloadFilenameFormat": "Filename Format",
|
"downloadFilenameFormat": "文件名格式",
|
||||||
"@downloadFilenameFormat": {
|
"@downloadFilenameFormat": {
|
||||||
"description": "Setting for output filename pattern"
|
"description": "Setting for output filename pattern"
|
||||||
},
|
},
|
||||||
"downloadFolderOrganization": "Folder Organization",
|
"downloadFolderOrganization": "文件夹结构",
|
||||||
"@downloadFolderOrganization": {
|
"@downloadFolderOrganization": {
|
||||||
"description": "Setting for folder structure"
|
"description": "Setting for folder structure"
|
||||||
},
|
},
|
||||||
"appearanceTitle": "Appearance",
|
"appearanceTitle": "外观",
|
||||||
"@appearanceTitle": {
|
"@appearanceTitle": {
|
||||||
"description": "Appearance settings page title"
|
"description": "Appearance settings page title"
|
||||||
},
|
},
|
||||||
"appearanceThemeSystem": "System",
|
"appearanceThemeSystem": "系统",
|
||||||
"@appearanceThemeSystem": {
|
"@appearanceThemeSystem": {
|
||||||
"description": "Follow system theme"
|
"description": "Follow system theme"
|
||||||
},
|
},
|
||||||
"appearanceThemeLight": "Light",
|
"appearanceThemeLight": "浅色",
|
||||||
"@appearanceThemeLight": {
|
"@appearanceThemeLight": {
|
||||||
"description": "Light theme"
|
"description": "Light theme"
|
||||||
},
|
},
|
||||||
"appearanceThemeDark": "Dark",
|
"appearanceThemeDark": "深色",
|
||||||
"@appearanceThemeDark": {
|
"@appearanceThemeDark": {
|
||||||
"description": "Dark theme"
|
"description": "Dark theme"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColor": "Dynamic Color",
|
"appearanceDynamicColor": "动态色彩",
|
||||||
"@appearanceDynamicColor": {
|
"@appearanceDynamicColor": {
|
||||||
"description": "Material You dynamic colors"
|
"description": "Material You dynamic colors"
|
||||||
},
|
},
|
||||||
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper",
|
"appearanceDynamicColorSubtitle": "使用壁纸的颜色",
|
||||||
"@appearanceDynamicColorSubtitle": {
|
"@appearanceDynamicColorSubtitle": {
|
||||||
"description": "Subtitle for dynamic color"
|
"description": "Subtitle for dynamic color"
|
||||||
},
|
},
|
||||||
"appearanceHistoryView": "History View",
|
"appearanceHistoryView": "历史记录",
|
||||||
"@appearanceHistoryView": {
|
"@appearanceHistoryView": {
|
||||||
"description": "Layout style for history"
|
"description": "Layout style for history"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewList": "List",
|
"appearanceHistoryViewList": "列表",
|
||||||
"@appearanceHistoryViewList": {
|
"@appearanceHistoryViewList": {
|
||||||
"description": "List layout option"
|
"description": "List layout option"
|
||||||
},
|
},
|
||||||
"appearanceHistoryViewGrid": "Grid",
|
"appearanceHistoryViewGrid": "网格",
|
||||||
"@appearanceHistoryViewGrid": {
|
"@appearanceHistoryViewGrid": {
|
||||||
"description": "Grid layout option"
|
"description": "Grid layout option"
|
||||||
},
|
},
|
||||||
"optionsTitle": "Options",
|
"optionsTitle": "选项",
|
||||||
"@optionsTitle": {
|
"@optionsTitle": {
|
||||||
"description": "Options settings page title"
|
"description": "Options settings page title"
|
||||||
},
|
},
|
||||||
"optionsPrimaryProvider": "Primary Provider",
|
"optionsPrimaryProvider": "主要提供者",
|
||||||
"@optionsPrimaryProvider": {
|
"@optionsPrimaryProvider": {
|
||||||
"description": "Main search provider setting"
|
"description": "Main search provider setting"
|
||||||
},
|
},
|
||||||
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.",
|
"optionsPrimaryProviderSubtitle": "按歌曲名称搜索时使用的服务。",
|
||||||
"@optionsPrimaryProviderSubtitle": {
|
"@optionsPrimaryProviderSubtitle": {
|
||||||
"description": "Subtitle for primary provider"
|
"description": "Subtitle for primary provider"
|
||||||
},
|
},
|
||||||
"optionsUsingExtension": "Using extension: {extensionName}",
|
"optionsUsingExtension": "使用扩展:{extensionName}",
|
||||||
"@optionsUsingExtension": {
|
"@optionsUsingExtension": {
|
||||||
"description": "Shows active extension name",
|
"description": "Shows active extension name",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -150,55 +150,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
"optionsSwitchBack": "点击 Deezer 或 Spotify 即可从扩展程序切换回来",
|
||||||
"@optionsSwitchBack": {
|
"@optionsSwitchBack": {
|
||||||
"description": "Hint to switch back to built-in providers"
|
"description": "Hint to switch back to built-in providers"
|
||||||
},
|
},
|
||||||
"optionsAutoFallback": "Auto Fallback",
|
"optionsAutoFallback": "自动回退",
|
||||||
"@optionsAutoFallback": {
|
"@optionsAutoFallback": {
|
||||||
"description": "Auto-retry with other services"
|
"description": "Auto-retry with other services"
|
||||||
},
|
},
|
||||||
"optionsAutoFallbackSubtitle": "Try other services if download fails",
|
"optionsAutoFallbackSubtitle": "如果下载失败,请尝试其他服务",
|
||||||
"@optionsAutoFallbackSubtitle": {
|
"@optionsAutoFallbackSubtitle": {
|
||||||
"description": "Subtitle for auto fallback"
|
"description": "Subtitle for auto fallback"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
"optionsUseExtensionProviders": "使用扩展提供商",
|
||||||
"@optionsUseExtensionProviders": {
|
"@optionsUseExtensionProviders": {
|
||||||
"description": "Enable extension download providers"
|
"description": "Enable extension download providers"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
"optionsUseExtensionProvidersOn": "扩展会被最先尝试",
|
||||||
"@optionsUseExtensionProvidersOn": {
|
"@optionsUseExtensionProvidersOn": {
|
||||||
"description": "Status when extension providers enabled"
|
"description": "Status when extension providers enabled"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
"optionsUseExtensionProvidersOff": "仅使用内置提供商",
|
||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Status when extension providers disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "Embed Lyrics",
|
"optionsEmbedLyrics": "内嵌歌词",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
"description": "Embed lyrics in audio files"
|
"description": "Embed lyrics in audio files"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files",
|
"optionsEmbedLyricsSubtitle": "嵌入已同步歌词到 FLAC 文件",
|
||||||
"@optionsEmbedLyricsSubtitle": {
|
"@optionsEmbedLyricsSubtitle": {
|
||||||
"description": "Subtitle for embed lyrics"
|
"description": "Subtitle for embed lyrics"
|
||||||
},
|
},
|
||||||
"optionsMaxQualityCover": "Max Quality Cover",
|
"optionsMaxQualityCover": "最高质量封面",
|
||||||
"@optionsMaxQualityCover": {
|
"@optionsMaxQualityCover": {
|
||||||
"description": "Download highest quality album art"
|
"description": "Download highest quality album art"
|
||||||
},
|
},
|
||||||
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art",
|
"optionsMaxQualityCoverSubtitle": "下载最高分辨率封面",
|
||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"description": "Subtitle for max quality cover"
|
||||||
},
|
},
|
||||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
"optionsConcurrentDownloads": "并行下载数",
|
||||||
"@optionsConcurrentDownloads": {
|
"@optionsConcurrentDownloads": {
|
||||||
"description": "Number of parallel downloads"
|
"description": "Number of parallel downloads"
|
||||||
},
|
},
|
||||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
"optionsConcurrentSequential": "按顺序下载(一次一首)",
|
||||||
"@optionsConcurrentSequential": {
|
"@optionsConcurrentSequential": {
|
||||||
"description": "Download one at a time"
|
"description": "Download one at a time"
|
||||||
},
|
},
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
"optionsConcurrentParallel": "同时下载 {count} 首",
|
||||||
"@optionsConcurrentParallel": {
|
"@optionsConcurrentParallel": {
|
||||||
"description": "Multiple parallel downloads",
|
"description": "Multiple parallel downloads",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -207,67 +207,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
"optionsConcurrentWarning": "并行下载可能会触发速率限制",
|
||||||
"@optionsConcurrentWarning": {
|
"@optionsConcurrentWarning": {
|
||||||
"description": "Warning about rate limits"
|
"description": "Warning about rate limits"
|
||||||
},
|
},
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "扩展商店",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
},
|
},
|
||||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
"optionsExtensionStoreSubtitle": "在导航中显示商店标签",
|
||||||
"@optionsExtensionStoreSubtitle": {
|
"@optionsExtensionStoreSubtitle": {
|
||||||
"description": "Subtitle for extension store toggle"
|
"description": "Subtitle for extension store toggle"
|
||||||
},
|
},
|
||||||
"optionsCheckUpdates": "Check for Updates",
|
"optionsCheckUpdates": "检查更新",
|
||||||
"@optionsCheckUpdates": {
|
"@optionsCheckUpdates": {
|
||||||
"description": "Auto update check toggle"
|
"description": "Auto update check toggle"
|
||||||
},
|
},
|
||||||
"optionsCheckUpdatesSubtitle": "Notify when new version is available",
|
"optionsCheckUpdatesSubtitle": "当有新版本可用时通知",
|
||||||
"@optionsCheckUpdatesSubtitle": {
|
"@optionsCheckUpdatesSubtitle": {
|
||||||
"description": "Subtitle for update check"
|
"description": "Subtitle for update check"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannel": "Update Channel",
|
"optionsUpdateChannel": "更新频道",
|
||||||
"@optionsUpdateChannel": {
|
"@optionsUpdateChannel": {
|
||||||
"description": "Stable vs preview releases"
|
"description": "Stable vs preview releases"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelStable": "Stable releases only",
|
"optionsUpdateChannelStable": "仅稳定版本",
|
||||||
"@optionsUpdateChannelStable": {
|
"@optionsUpdateChannelStable": {
|
||||||
"description": "Only stable updates"
|
"description": "Only stable updates"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelPreview": "Get preview releases",
|
"optionsUpdateChannelPreview": "获取预览版本",
|
||||||
"@optionsUpdateChannelPreview": {
|
"@optionsUpdateChannelPreview": {
|
||||||
"description": "Include beta/preview updates"
|
"description": "Include beta/preview updates"
|
||||||
},
|
},
|
||||||
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features",
|
"optionsUpdateChannelWarning": "预览版本可能包含错误或者尚未完善的功能",
|
||||||
"@optionsUpdateChannelWarning": {
|
"@optionsUpdateChannelWarning": {
|
||||||
"description": "Warning about preview channel"
|
"description": "Warning about preview channel"
|
||||||
},
|
},
|
||||||
"optionsClearHistory": "Clear Download History",
|
"optionsClearHistory": "清除下载历史记录",
|
||||||
"@optionsClearHistory": {
|
"@optionsClearHistory": {
|
||||||
"description": "Delete all download history"
|
"description": "Delete all download history"
|
||||||
},
|
},
|
||||||
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history",
|
"optionsClearHistorySubtitle": "从历史记录中清除所有已下载的曲目",
|
||||||
"@optionsClearHistorySubtitle": {
|
"@optionsClearHistorySubtitle": {
|
||||||
"description": "Subtitle for clear history"
|
"description": "Subtitle for clear history"
|
||||||
},
|
},
|
||||||
"optionsDetailedLogging": "Detailed Logging",
|
"optionsDetailedLogging": "详细日志",
|
||||||
"@optionsDetailedLogging": {
|
"@optionsDetailedLogging": {
|
||||||
"description": "Enable verbose logs for debugging"
|
"description": "Enable verbose logs for debugging"
|
||||||
},
|
},
|
||||||
"optionsDetailedLoggingOn": "Detailed logs are being recorded",
|
"optionsDetailedLoggingOn": "正在记录详细日志",
|
||||||
"@optionsDetailedLoggingOn": {
|
"@optionsDetailedLoggingOn": {
|
||||||
"description": "Status when logging enabled"
|
"description": "Status when logging enabled"
|
||||||
},
|
},
|
||||||
"optionsDetailedLoggingOff": "Enable for bug reports",
|
"optionsDetailedLoggingOff": "为错误报告启用",
|
||||||
"@optionsDetailedLoggingOff": {
|
"@optionsDetailedLoggingOff": {
|
||||||
"description": "Status when logging disabled"
|
"description": "Status when logging disabled"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentials": "Spotify Credentials",
|
"optionsSpotifyCredentials": "Spotify 凭据",
|
||||||
"@optionsSpotifyCredentials": {
|
"@optionsSpotifyCredentials": {
|
||||||
"description": "Spotify API credentials setting"
|
"description": "Spotify API credentials setting"
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
|
"optionsSpotifyCredentialsConfigured": "客户端 ID:{clientId}……",
|
||||||
"@optionsSpotifyCredentialsConfigured": {
|
"@optionsSpotifyCredentialsConfigured": {
|
||||||
"description": "Shows configured client ID preview",
|
"description": "Shows configured client ID preview",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -276,27 +276,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSpotifyCredentialsRequired": "Required - tap to configure",
|
"optionsSpotifyCredentialsRequired": "必填 - 点击配置",
|
||||||
"@optionsSpotifyCredentialsRequired": {
|
"@optionsSpotifyCredentialsRequired": {
|
||||||
"description": "Prompt to set up credentials"
|
"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": {
|
"@optionsSpotifyWarning": {
|
||||||
"description": "Info about Spotify API requirement"
|
"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": {
|
"@optionsSpotifyDeprecationWarning": {
|
||||||
"description": "Warning about Spotify API deprecation"
|
"description": "Warning about Spotify API deprecation"
|
||||||
},
|
},
|
||||||
"extensionsTitle": "Extensions",
|
"extensionsTitle": "扩展",
|
||||||
"@extensionsTitle": {
|
"@extensionsTitle": {
|
||||||
"description": "Extensions page title"
|
"description": "Extensions page title"
|
||||||
},
|
},
|
||||||
"extensionsDisabled": "Disabled",
|
"extensionsDisabled": "禁用",
|
||||||
"@extensionsDisabled": {
|
"@extensionsDisabled": {
|
||||||
"description": "Extension status - inactive"
|
"description": "Extension status - inactive"
|
||||||
},
|
},
|
||||||
"extensionsVersion": "Version {version}",
|
"extensionsVersion": "版本 {version}",
|
||||||
"@extensionsVersion": {
|
"@extensionsVersion": {
|
||||||
"description": "Extension version display",
|
"description": "Extension version display",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -305,7 +305,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionsAuthor": "by {author}",
|
"extensionsAuthor": "来自 {author}",
|
||||||
"@extensionsAuthor": {
|
"@extensionsAuthor": {
|
||||||
"description": "Extension author credit",
|
"description": "Extension author credit",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -314,75 +314,75 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensionsUninstall": "Uninstall",
|
"extensionsUninstall": "卸载",
|
||||||
"@extensionsUninstall": {
|
"@extensionsUninstall": {
|
||||||
"description": "Uninstall extension button"
|
"description": "Uninstall extension button"
|
||||||
},
|
},
|
||||||
"storeTitle": "Extension Store",
|
"storeTitle": "扩展商店",
|
||||||
"@storeTitle": {
|
"@storeTitle": {
|
||||||
"description": "Store screen title"
|
"description": "Store screen title"
|
||||||
},
|
},
|
||||||
"storeSearch": "Search extensions...",
|
"storeSearch": "搜索扩展……",
|
||||||
"@storeSearch": {
|
"@storeSearch": {
|
||||||
"description": "Store search placeholder"
|
"description": "Store search placeholder"
|
||||||
},
|
},
|
||||||
"storeInstall": "Install",
|
"storeInstall": "安装",
|
||||||
"@storeInstall": {
|
"@storeInstall": {
|
||||||
"description": "Install extension button"
|
"description": "Install extension button"
|
||||||
},
|
},
|
||||||
"storeInstalled": "Installed",
|
"storeInstalled": "已安装",
|
||||||
"@storeInstalled": {
|
"@storeInstalled": {
|
||||||
"description": "Already installed badge"
|
"description": "Already installed badge"
|
||||||
},
|
},
|
||||||
"storeUpdate": "Update",
|
"storeUpdate": "更新",
|
||||||
"@storeUpdate": {
|
"@storeUpdate": {
|
||||||
"description": "Update available button"
|
"description": "Update available button"
|
||||||
},
|
},
|
||||||
"aboutTitle": "About",
|
"aboutTitle": "关于",
|
||||||
"@aboutTitle": {
|
"@aboutTitle": {
|
||||||
"description": "About page title"
|
"description": "About page title"
|
||||||
},
|
},
|
||||||
"aboutContributors": "Contributors",
|
"aboutContributors": "贡献者",
|
||||||
"@aboutContributors": {
|
"@aboutContributors": {
|
||||||
"description": "Section for contributors"
|
"description": "Section for contributors"
|
||||||
},
|
},
|
||||||
"aboutMobileDeveloper": "Mobile version developer",
|
"aboutMobileDeveloper": "移动版本开发者",
|
||||||
"@aboutMobileDeveloper": {
|
"@aboutMobileDeveloper": {
|
||||||
"description": "Role description for mobile dev"
|
"description": "Role description for mobile dev"
|
||||||
},
|
},
|
||||||
"aboutOriginalCreator": "Creator of the original SpotiFLAC",
|
"aboutOriginalCreator": "原 SpotiLDAC 创建者",
|
||||||
"@aboutOriginalCreator": {
|
"@aboutOriginalCreator": {
|
||||||
"description": "Role description for original creator"
|
"description": "Role description for original creator"
|
||||||
},
|
},
|
||||||
"aboutLogoArtist": "The talented artist who created our beautiful app logo!",
|
"aboutLogoArtist": "有才华的艺术家创建了我们美丽的应用图标!",
|
||||||
"@aboutLogoArtist": {
|
"@aboutLogoArtist": {
|
||||||
"description": "Role description for logo artist"
|
"description": "Role description for logo artist"
|
||||||
},
|
},
|
||||||
"aboutTranslators": "Translators",
|
"aboutTranslators": "译者",
|
||||||
"@aboutTranslators": {
|
"@aboutTranslators": {
|
||||||
"description": "Section for translators"
|
"description": "Section for translators"
|
||||||
},
|
},
|
||||||
"aboutSpecialThanks": "Special Thanks",
|
"aboutSpecialThanks": "特别鸣谢",
|
||||||
"@aboutSpecialThanks": {
|
"@aboutSpecialThanks": {
|
||||||
"description": "Section for special thanks"
|
"description": "Section for special thanks"
|
||||||
},
|
},
|
||||||
"aboutLinks": "Links",
|
"aboutLinks": "相关链接",
|
||||||
"@aboutLinks": {
|
"@aboutLinks": {
|
||||||
"description": "Section for external links"
|
"description": "Section for external links"
|
||||||
},
|
},
|
||||||
"aboutMobileSource": "Mobile source code",
|
"aboutMobileSource": "移动版本源代码",
|
||||||
"@aboutMobileSource": {
|
"@aboutMobileSource": {
|
||||||
"description": "Link to mobile GitHub repo"
|
"description": "Link to mobile GitHub repo"
|
||||||
},
|
},
|
||||||
"aboutPCSource": "PC source code",
|
"aboutPCSource": "桌面版本源代码",
|
||||||
"@aboutPCSource": {
|
"@aboutPCSource": {
|
||||||
"description": "Link to PC GitHub repo"
|
"description": "Link to PC GitHub repo"
|
||||||
},
|
},
|
||||||
"aboutReportIssue": "Report an issue",
|
"aboutReportIssue": "报告一个问题",
|
||||||
"@aboutReportIssue": {
|
"@aboutReportIssue": {
|
||||||
"description": "Link to report bugs"
|
"description": "Link to report bugs"
|
||||||
},
|
},
|
||||||
"aboutReportIssueSubtitle": "Report any problems you encounter",
|
"aboutReportIssueSubtitle": "报告您遇到的任何问题",
|
||||||
"@aboutReportIssueSubtitle": {
|
"@aboutReportIssueSubtitle": {
|
||||||
"description": "Subtitle for report issue"
|
"description": "Subtitle for report issue"
|
||||||
},
|
},
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -603,23 +603,23 @@
|
|||||||
"@setupNotificationGranted": {
|
"@setupNotificationGranted": {
|
||||||
"description": "Success message for notification permission"
|
"description": "Success message for notification permission"
|
||||||
},
|
},
|
||||||
"setupNotificationEnable": "Enable Notifications",
|
"setupNotificationEnable": "启用通知",
|
||||||
"@setupNotificationEnable": {
|
"@setupNotificationEnable": {
|
||||||
"description": "Button to enable notifications"
|
"description": "Button to enable notifications"
|
||||||
},
|
},
|
||||||
"setupFolderChoose": "Choose Download Folder",
|
"setupFolderChoose": "选择下载文件夹",
|
||||||
"@setupFolderChoose": {
|
"@setupFolderChoose": {
|
||||||
"description": "Button to choose folder"
|
"description": "Button to choose folder"
|
||||||
},
|
},
|
||||||
"setupFolderDescription": "Select a folder where your downloaded music will be saved.",
|
"setupFolderDescription": "选择保存您下载的音乐的文件夹。",
|
||||||
"@setupFolderDescription": {
|
"@setupFolderDescription": {
|
||||||
"description": "Explanation for folder selection"
|
"description": "Explanation for folder selection"
|
||||||
},
|
},
|
||||||
"setupSelectFolder": "Select Folder",
|
"setupSelectFolder": "选择文件夹",
|
||||||
"@setupSelectFolder": {
|
"@setupSelectFolder": {
|
||||||
"description": "Button to select folder"
|
"description": "Button to select folder"
|
||||||
},
|
},
|
||||||
"setupEnableNotifications": "Enable Notifications",
|
"setupEnableNotifications": "启用通知",
|
||||||
"@setupEnableNotifications": {
|
"@setupEnableNotifications": {
|
||||||
"description": "Button to enable notifications"
|
"description": "Button to enable notifications"
|
||||||
},
|
},
|
||||||
@@ -889,14 +889,26 @@
|
|||||||
"@errorRateLimited": {
|
"@errorRateLimited": {
|
||||||
"description": "Error title - too many requests"
|
"description": "Error title - too many requests"
|
||||||
},
|
},
|
||||||
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.",
|
"errorRateLimitedMessage": "请求过多。请等一会再搜索。",
|
||||||
"@errorRateLimitedMessage": {
|
"@errorRateLimitedMessage": {
|
||||||
"description": "Error message - rate limit explanation"
|
"description": "Error message - rate limit explanation"
|
||||||
},
|
},
|
||||||
"errorNoTracksFound": "No tracks found",
|
"errorNoTracksFound": "未找到曲目",
|
||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+107
-15
@@ -450,7 +450,7 @@
|
|||||||
"@aboutSpotiSaverDesc": {
|
"@aboutSpotiSaverDesc": {
|
||||||
"description": "Credit for SpotiSaver API"
|
"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": {
|
"@aboutAppDescription": {
|
||||||
"description": "App description in header card"
|
"description": "App description in header card"
|
||||||
},
|
},
|
||||||
@@ -897,6 +897,18 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"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": "Cannot load {item}: missing extension source",
|
||||||
"@errorMissingExtensionSource": {
|
"@errorMissingExtensionSource": {
|
||||||
"description": "Error - extension source not available",
|
"description": "Error - extension source not available",
|
||||||
@@ -1003,6 +1015,14 @@
|
|||||||
"@folderOrganizationNone": {
|
"@folderOrganizationNone": {
|
||||||
"description": "Folder option - flat structure"
|
"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": "By Artist",
|
||||||
"@folderOrganizationByArtist": {
|
"@folderOrganizationByArtist": {
|
||||||
"description": "Folder option - artist folders"
|
"description": "Folder option - artist folders"
|
||||||
@@ -1097,7 +1117,7 @@
|
|||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Built-in",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)"
|
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1753,18 +1773,6 @@
|
|||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
"description": "Note about quality availability"
|
"description": "Note about quality availability"
|
||||||
},
|
},
|
||||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
|
||||||
"@youtubeQualityNote": {
|
|
||||||
"description": "Note for YouTube service explaining lossy-only quality"
|
|
||||||
},
|
|
||||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
|
||||||
"@youtubeOpusBitrateTitle": {
|
|
||||||
"description": "Title for YouTube Opus bitrate setting"
|
|
||||||
},
|
|
||||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
|
||||||
"@youtubeMp3BitrateTitle": {
|
|
||||||
"description": "Title for YouTube MP3 bitrate setting"
|
|
||||||
},
|
|
||||||
"downloadAskBeforeDownload": "Ask Before Download",
|
"downloadAskBeforeDownload": "Ask Before Download",
|
||||||
"@downloadAskBeforeDownload": {
|
"@downloadAskBeforeDownload": {
|
||||||
"description": "Setting - show quality picker"
|
"description": "Setting - show quality picker"
|
||||||
@@ -2383,7 +2391,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"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": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -2808,6 +2816,90 @@
|
|||||||
"@trackConvertFailed": {
|
"@trackConvertFailed": {
|
||||||
"description": "Snackbar when conversion fails"
|
"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": "Create",
|
||||||
"@actionCreate": {
|
"@actionCreate": {
|
||||||
"description": "Generic action button - create"
|
"description": "Generic action button - create"
|
||||||
|
|||||||
+134
-6
@@ -1,16 +1,20 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.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/app.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_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/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/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
|
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -88,15 +92,139 @@ class _EagerInitialization extends ConsumerStatefulWidget {
|
|||||||
_EagerInitializationState();
|
_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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initializeAppServices();
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_initializeExtensions();
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ref.read(downloadHistoryProvider);
|
if (!mounted) return;
|
||||||
ref.read(localLibraryProvider);
|
_initializeAppServices();
|
||||||
ref.read(libraryCollectionsProvider);
|
_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;
|
||||||
|
|
||||||
|
final libraryState = ref.read(localLibraryProvider);
|
||||||
|
if (libraryState.isScanning) return;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final iosBookmark = settings.localLibraryBookmark;
|
||||||
|
ref
|
||||||
|
.read(localLibraryProvider.notifier)
|
||||||
|
.startScan(
|
||||||
|
settings.localLibraryPath,
|
||||||
|
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeAppServices() async {
|
Future<void> _initializeAppServices() async {
|
||||||
|
|||||||
@@ -12,13 +12,7 @@ enum DownloadStatus {
|
|||||||
skipped,
|
skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DownloadErrorType {
|
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
|
||||||
unknown,
|
|
||||||
notFound,
|
|
||||||
rateLimit,
|
|
||||||
network,
|
|
||||||
permission,
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class DownloadItem {
|
class DownloadItem {
|
||||||
@@ -28,7 +22,8 @@ class DownloadItem {
|
|||||||
final DownloadStatus status;
|
final DownloadStatus status;
|
||||||
final double progress;
|
final double progress;
|
||||||
final double speedMBps;
|
final double speedMBps;
|
||||||
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
|
final int bytesReceived; // Bytes downloaded so far
|
||||||
|
final int bytesTotal; // Total bytes when the server provides content length
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
final String? error;
|
final String? error;
|
||||||
final DownloadErrorType? errorType;
|
final DownloadErrorType? errorType;
|
||||||
@@ -44,6 +39,7 @@ class DownloadItem {
|
|||||||
this.progress = 0.0,
|
this.progress = 0.0,
|
||||||
this.speedMBps = 0.0,
|
this.speedMBps = 0.0,
|
||||||
this.bytesReceived = 0,
|
this.bytesReceived = 0,
|
||||||
|
this.bytesTotal = 0,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
this.error,
|
this.error,
|
||||||
this.errorType,
|
this.errorType,
|
||||||
@@ -60,6 +56,7 @@ class DownloadItem {
|
|||||||
double? progress,
|
double? progress,
|
||||||
double? speedMBps,
|
double? speedMBps,
|
||||||
int? bytesReceived,
|
int? bytesReceived,
|
||||||
|
int? bytesTotal,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
String? error,
|
String? error,
|
||||||
DownloadErrorType? errorType,
|
DownloadErrorType? errorType,
|
||||||
@@ -75,6 +72,7 @@ class DownloadItem {
|
|||||||
progress: progress ?? this.progress,
|
progress: progress ?? this.progress,
|
||||||
speedMBps: speedMBps ?? this.speedMBps,
|
speedMBps: speedMBps ?? this.speedMBps,
|
||||||
bytesReceived: bytesReceived ?? this.bytesReceived,
|
bytesReceived: bytesReceived ?? this.bytesReceived,
|
||||||
|
bytesTotal: bytesTotal ?? this.bytesTotal,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
errorType: errorType ?? this.errorType,
|
errorType: errorType ?? this.errorType,
|
||||||
@@ -86,7 +84,7 @@ class DownloadItem {
|
|||||||
|
|
||||||
String get errorMessage {
|
String get errorMessage {
|
||||||
if (error == null) return '';
|
if (error == null) return '';
|
||||||
|
|
||||||
switch (errorType) {
|
switch (errorType) {
|
||||||
case DownloadErrorType.notFound:
|
case DownloadErrorType.notFound:
|
||||||
return 'Song not found on any service';
|
return 'Song not found on any service';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
|||||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
|
||||||
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
|
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
|
||||||
|
bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0,
|
||||||
filePath: json['filePath'] as String?,
|
filePath: json['filePath'] as String?,
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
|
||||||
@@ -33,6 +34,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
|||||||
'progress': instance.progress,
|
'progress': instance.progress,
|
||||||
'speedMBps': instance.speedMBps,
|
'speedMBps': instance.speedMBps,
|
||||||
'bytesReceived': instance.bytesReceived,
|
'bytesReceived': instance.bytesReceived,
|
||||||
|
'bytesTotal': instance.bytesTotal,
|
||||||
'filePath': instance.filePath,
|
'filePath': instance.filePath,
|
||||||
'error': instance.error,
|
'error': instance.error,
|
||||||
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
|
||||||
|
|||||||
+16
-10
@@ -20,6 +20,7 @@ class AppSettings {
|
|||||||
final String updateChannel;
|
final String updateChannel;
|
||||||
final bool hasSearchedBefore;
|
final bool hasSearchedBefore;
|
||||||
final String folderOrganization;
|
final String folderOrganization;
|
||||||
|
final bool createPlaylistFolder;
|
||||||
final bool useAlbumArtistForFolders;
|
final bool useAlbumArtistForFolders;
|
||||||
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||||
final bool filterContributingArtistsInAlbumArtist;
|
final bool filterContributingArtistsInAlbumArtist;
|
||||||
@@ -33,6 +34,7 @@ class AppSettings {
|
|||||||
final bool enableLogging;
|
final bool enableLogging;
|
||||||
final bool useExtensionProviders;
|
final bool useExtensionProviders;
|
||||||
final String? searchProvider;
|
final String? searchProvider;
|
||||||
|
final String? homeFeedProvider;
|
||||||
final bool separateSingles;
|
final bool separateSingles;
|
||||||
final String albumFolderStructure;
|
final String albumFolderStructure;
|
||||||
final bool showExtensionStore;
|
final bool showExtensionStore;
|
||||||
@@ -40,10 +42,6 @@ class AppSettings {
|
|||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String
|
final String
|
||||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||||
final int
|
|
||||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
|
|
||||||
final int
|
|
||||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
|
||||||
final bool
|
final bool
|
||||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
final bool
|
final bool
|
||||||
@@ -61,6 +59,8 @@ class AppSettings {
|
|||||||
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||||
final bool
|
final bool
|
||||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||||
|
final String
|
||||||
|
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
||||||
|
|
||||||
final bool
|
final bool
|
||||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||||
@@ -96,6 +96,7 @@ class AppSettings {
|
|||||||
this.updateChannel = 'stable',
|
this.updateChannel = 'stable',
|
||||||
this.hasSearchedBefore = false,
|
this.hasSearchedBefore = false,
|
||||||
this.folderOrganization = 'none',
|
this.folderOrganization = 'none',
|
||||||
|
this.createPlaylistFolder = false,
|
||||||
this.useAlbumArtistForFolders = true,
|
this.useAlbumArtistForFolders = true,
|
||||||
this.usePrimaryArtistOnly = false,
|
this.usePrimaryArtistOnly = false,
|
||||||
this.filterContributingArtistsInAlbumArtist = false,
|
this.filterContributingArtistsInAlbumArtist = false,
|
||||||
@@ -109,14 +110,13 @@ class AppSettings {
|
|||||||
this.enableLogging = false,
|
this.enableLogging = false,
|
||||||
this.useExtensionProviders = true,
|
this.useExtensionProviders = true,
|
||||||
this.searchProvider,
|
this.searchProvider,
|
||||||
|
this.homeFeedProvider,
|
||||||
this.separateSingles = false,
|
this.separateSingles = false,
|
||||||
this.albumFolderStructure = 'artist_album',
|
this.albumFolderStructure = 'artist_album',
|
||||||
this.showExtensionStore = true,
|
this.showExtensionStore = true,
|
||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
this.tidalHighFormat = 'mp3_320',
|
this.tidalHighFormat = 'mp3_320',
|
||||||
this.youtubeOpusBitrate = 256,
|
|
||||||
this.youtubeMp3Bitrate = 320,
|
|
||||||
this.useAllFilesAccess = false,
|
this.useAllFilesAccess = false,
|
||||||
this.autoExportFailedDownloads = false,
|
this.autoExportFailedDownloads = false,
|
||||||
this.downloadNetworkMode = 'any',
|
this.downloadNetworkMode = 'any',
|
||||||
@@ -126,6 +126,7 @@ class AppSettings {
|
|||||||
this.localLibraryPath = '',
|
this.localLibraryPath = '',
|
||||||
this.localLibraryBookmark = '',
|
this.localLibraryBookmark = '',
|
||||||
this.localLibraryShowDuplicates = true,
|
this.localLibraryShowDuplicates = true,
|
||||||
|
this.localLibraryAutoScan = 'off',
|
||||||
this.hasCompletedTutorial = false,
|
this.hasCompletedTutorial = false,
|
||||||
this.lyricsProviders = const [
|
this.lyricsProviders = const [
|
||||||
'lrclib',
|
'lrclib',
|
||||||
@@ -159,6 +160,7 @@ class AppSettings {
|
|||||||
String? updateChannel,
|
String? updateChannel,
|
||||||
bool? hasSearchedBefore,
|
bool? hasSearchedBefore,
|
||||||
String? folderOrganization,
|
String? folderOrganization,
|
||||||
|
bool? createPlaylistFolder,
|
||||||
bool? useAlbumArtistForFolders,
|
bool? useAlbumArtistForFolders,
|
||||||
bool? usePrimaryArtistOnly,
|
bool? usePrimaryArtistOnly,
|
||||||
bool? filterContributingArtistsInAlbumArtist,
|
bool? filterContributingArtistsInAlbumArtist,
|
||||||
@@ -173,14 +175,14 @@ class AppSettings {
|
|||||||
bool? useExtensionProviders,
|
bool? useExtensionProviders,
|
||||||
String? searchProvider,
|
String? searchProvider,
|
||||||
bool clearSearchProvider = false,
|
bool clearSearchProvider = false,
|
||||||
|
String? homeFeedProvider,
|
||||||
|
bool clearHomeFeedProvider = false,
|
||||||
bool? separateSingles,
|
bool? separateSingles,
|
||||||
String? albumFolderStructure,
|
String? albumFolderStructure,
|
||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
String? locale,
|
String? locale,
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
String? tidalHighFormat,
|
String? tidalHighFormat,
|
||||||
int? youtubeOpusBitrate,
|
|
||||||
int? youtubeMp3Bitrate,
|
|
||||||
bool? useAllFilesAccess,
|
bool? useAllFilesAccess,
|
||||||
bool? autoExportFailedDownloads,
|
bool? autoExportFailedDownloads,
|
||||||
String? downloadNetworkMode,
|
String? downloadNetworkMode,
|
||||||
@@ -190,6 +192,7 @@ class AppSettings {
|
|||||||
String? localLibraryPath,
|
String? localLibraryPath,
|
||||||
String? localLibraryBookmark,
|
String? localLibraryBookmark,
|
||||||
bool? localLibraryShowDuplicates,
|
bool? localLibraryShowDuplicates,
|
||||||
|
String? localLibraryAutoScan,
|
||||||
bool? hasCompletedTutorial,
|
bool? hasCompletedTutorial,
|
||||||
List<String>? lyricsProviders,
|
List<String>? lyricsProviders,
|
||||||
bool? lyricsIncludeTranslationNetease,
|
bool? lyricsIncludeTranslationNetease,
|
||||||
@@ -215,6 +218,7 @@ class AppSettings {
|
|||||||
updateChannel: updateChannel ?? this.updateChannel,
|
updateChannel: updateChannel ?? this.updateChannel,
|
||||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||||
|
createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder,
|
||||||
useAlbumArtistForFolders:
|
useAlbumArtistForFolders:
|
||||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||||
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||||
@@ -236,14 +240,15 @@ class AppSettings {
|
|||||||
searchProvider: clearSearchProvider
|
searchProvider: clearSearchProvider
|
||||||
? null
|
? null
|
||||||
: (searchProvider ?? this.searchProvider),
|
: (searchProvider ?? this.searchProvider),
|
||||||
|
homeFeedProvider: clearHomeFeedProvider
|
||||||
|
? null
|
||||||
|
: (homeFeedProvider ?? this.homeFeedProvider),
|
||||||
separateSingles: separateSingles ?? this.separateSingles,
|
separateSingles: separateSingles ?? this.separateSingles,
|
||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
|
||||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
autoExportFailedDownloads:
|
autoExportFailedDownloads:
|
||||||
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
|
||||||
@@ -256,6 +261,7 @@ class AppSettings {
|
|||||||
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||||
|
localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan,
|
||||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||||
lyricsIncludeTranslationNetease:
|
lyricsIncludeTranslationNetease:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||||
|
createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false,
|
||||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||||
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||||
filterContributingArtistsInAlbumArtist:
|
filterContributingArtistsInAlbumArtist:
|
||||||
@@ -38,6 +39,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||||
searchProvider: json['searchProvider'] as String?,
|
searchProvider: json['searchProvider'] as String?,
|
||||||
|
homeFeedProvider: json['homeFeedProvider'] as String?,
|
||||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||||
albumFolderStructure:
|
albumFolderStructure:
|
||||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||||
@@ -45,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
|
||||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
|
||||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||||
autoExportFailedDownloads:
|
autoExportFailedDownloads:
|
||||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||||
@@ -58,6 +58,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||||
|
localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
|
||||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||||
lyricsProviders:
|
lyricsProviders:
|
||||||
(json['lyricsProviders'] as List<dynamic>?)
|
(json['lyricsProviders'] as List<dynamic>?)
|
||||||
@@ -100,6 +101,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'updateChannel': instance.updateChannel,
|
'updateChannel': instance.updateChannel,
|
||||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||||
'folderOrganization': instance.folderOrganization,
|
'folderOrganization': instance.folderOrganization,
|
||||||
|
'createPlaylistFolder': instance.createPlaylistFolder,
|
||||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||||
'filterContributingArtistsInAlbumArtist':
|
'filterContributingArtistsInAlbumArtist':
|
||||||
@@ -114,14 +116,13 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'enableLogging': instance.enableLogging,
|
'enableLogging': instance.enableLogging,
|
||||||
'useExtensionProviders': instance.useExtensionProviders,
|
'useExtensionProviders': instance.useExtensionProviders,
|
||||||
'searchProvider': instance.searchProvider,
|
'searchProvider': instance.searchProvider,
|
||||||
|
'homeFeedProvider': instance.homeFeedProvider,
|
||||||
'separateSingles': instance.separateSingles,
|
'separateSingles': instance.separateSingles,
|
||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
|
||||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||||
@@ -131,6 +132,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'localLibraryPath': instance.localLibraryPath,
|
'localLibraryPath': instance.localLibraryPath,
|
||||||
'localLibraryBookmark': instance.localLibraryBookmark,
|
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||||
|
'localLibraryAutoScan': instance.localLibraryAutoScan,
|
||||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||||
'lyricsProviders': instance.lyricsProviders,
|
'lyricsProviders': instance.lyricsProviders,
|
||||||
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,14 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
|
||||||
final _log = AppLogger('ExploreProvider');
|
final _log = AppLogger('ExploreProvider');
|
||||||
|
|
||||||
class ExploreItem {
|
class ExploreItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String uri;
|
final String uri;
|
||||||
final String type; // track, album, playlist, artist, station
|
final String type;
|
||||||
final String name;
|
final String name;
|
||||||
final String artists;
|
final String artists;
|
||||||
final String? description;
|
final String? description;
|
||||||
@@ -167,7 +168,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
return const ExploreState();
|
return const ExploreState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
|
||||||
Future<void> _restoreFromCache() async {
|
Future<void> _restoreFromCache() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -198,13 +198,10 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
|
||||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final data = {
|
final data = {'sections': sections.map((s) => s.toJson()).toList()};
|
||||||
'sections': sections.map((s) => s.toJson()).toList(),
|
|
||||||
};
|
|
||||||
await prefs.setString(_cacheKey, jsonEncode(data));
|
await prefs.setString(_cacheKey, jsonEncode(data));
|
||||||
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
||||||
_log.d('Saved ${sections.length} explore sections to cache');
|
_log.d('Saved ${sections.length} explore sections to cache');
|
||||||
@@ -213,45 +210,52 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch home feed from spotify-web extension
|
|
||||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||||
|
|
||||||
// If we have cached content and it's fresh enough, skip network fetch
|
if (!forceRefresh &&
|
||||||
if (!forceRefresh &&
|
state.hasContent &&
|
||||||
state.hasContent &&
|
|
||||||
state.lastFetched != null &&
|
state.lastFetched != null &&
|
||||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||||
_log.d('Using cached home feed (fresh enough)');
|
_log.d('Using cached home feed (fresh enough)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
_log.d('Home feed fetch already in progress');
|
_log.d('Home feed fetch already in progress');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show loading spinner if we have no cached content to display
|
|
||||||
final showLoading = !state.hasContent;
|
final showLoading = !state.hasContent;
|
||||||
state = state.copyWith(isLoading: showLoading, error: null);
|
state = state.copyWith(isLoading: showLoading, error: null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final extState = ref.read(extensionProvider);
|
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;
|
Extension? targetExt;
|
||||||
for (final extension in extState.extensions) {
|
for (final extension in extState.extensions) {
|
||||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (preferredId != null &&
|
||||||
|
preferredId.isNotEmpty &&
|
||||||
|
extension.id == preferredId) {
|
||||||
|
targetExt = extension;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (targetExt == null || extension.id == 'spotify-web') {
|
if (targetExt == null || extension.id == 'spotify-web') {
|
||||||
targetExt = extension;
|
targetExt = extension;
|
||||||
if (extension.id == 'spotify-web') {
|
if (preferredId == null && extension.id == 'spotify-web') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetExt == null) {
|
if (targetExt == null) {
|
||||||
_log.w('No extension with homeFeed capability found');
|
_log.w('No extension with homeFeed capability found');
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -260,7 +264,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.i('Fetching home feed from ${targetExt.id}...');
|
_log.i('Fetching home feed from ${targetExt.id}...');
|
||||||
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
||||||
|
|
||||||
@@ -276,10 +280,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
_log.d('getExtensionHomeFeed success=$success');
|
_log.d('getExtensionHomeFeed success=$success');
|
||||||
if (!success) {
|
if (!success) {
|
||||||
final error = result['error'] as String? ?? 'Unknown error';
|
final error = result['error'] as String? ?? 'Unknown error';
|
||||||
state = state.copyWith(
|
state = state.copyWith(isLoading: false, error: error);
|
||||||
isLoading: false,
|
|
||||||
error: error,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,10 +292,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
_log.i('Fetched ${sections.length} sections');
|
_log.i('Fetched ${sections.length} sections');
|
||||||
|
|
||||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||||
final firstItem = sections.first.items.first;
|
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();
|
final localGreeting = _getLocalGreeting();
|
||||||
@@ -307,14 +310,10 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
lastFetched: DateTime.now(),
|
lastFetched: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save to disk cache for instant restore on next app launch
|
|
||||||
_saveToCache(sections);
|
_saveToCache(sections);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
_log.e('Error fetching home feed: $e', e, stack);
|
_log.e('Error fetching home feed: $e', e, stack);
|
||||||
state = state.copyWith(
|
state = state.copyWith(isLoading: false, error: e.toString());
|
||||||
isLoading: false,
|
|
||||||
error: e.toString(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +324,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
|||||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||||
return ExploreNotifier();
|
return ExploreNotifier();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ class Extension {
|
|||||||
final bool hasMetadataProvider;
|
final bool hasMetadataProvider;
|
||||||
final bool hasDownloadProvider;
|
final bool hasDownloadProvider;
|
||||||
final bool hasLyricsProvider;
|
final bool hasLyricsProvider;
|
||||||
final bool
|
final bool skipMetadataEnrichment;
|
||||||
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
|
||||||
final SearchBehavior? searchBehavior;
|
final SearchBehavior? searchBehavior;
|
||||||
final URLHandler? urlHandler;
|
final URLHandler? urlHandler;
|
||||||
final TrackMatching? trackMatching;
|
final TrackMatching? trackMatching;
|
||||||
final PostProcessing? postProcessing;
|
final PostProcessing? postProcessing;
|
||||||
final Map<String, dynamic>
|
final Map<String, dynamic> capabilities;
|
||||||
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
|
||||||
|
|
||||||
const Extension({
|
const Extension({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -198,12 +196,10 @@ class SearchBehavior {
|
|||||||
final String? placeholder;
|
final String? placeholder;
|
||||||
final bool primary;
|
final bool primary;
|
||||||
final String? icon;
|
final String? icon;
|
||||||
final String?
|
final String? thumbnailRatio;
|
||||||
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
|
||||||
final int? thumbnailWidth;
|
final int? thumbnailWidth;
|
||||||
final int? thumbnailHeight;
|
final int? thumbnailHeight;
|
||||||
final List<SearchFilter>
|
final List<SearchFilter> filters;
|
||||||
filters; // Available search filters (e.g., track, album, artist, playlist)
|
|
||||||
|
|
||||||
const SearchBehavior({
|
const SearchBehavior({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
@@ -239,11 +235,11 @@ class SearchBehavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (thumbnailRatio) {
|
switch (thumbnailRatio) {
|
||||||
case 'wide': // 16:9 - YouTube style
|
case 'wide':
|
||||||
return (defaultSize * 16 / 9, defaultSize);
|
return (defaultSize * 16 / 9, defaultSize);
|
||||||
case 'portrait': // 2:3 - Poster style
|
case 'portrait':
|
||||||
return (defaultSize * 2 / 3, defaultSize);
|
return (defaultSize * 2 / 3, defaultSize);
|
||||||
case 'square': // 1:1 - Album art style
|
case 'square':
|
||||||
default:
|
default:
|
||||||
return (defaultSize, defaultSize);
|
return (defaultSize, defaultSize);
|
||||||
}
|
}
|
||||||
@@ -290,7 +286,6 @@ class PostProcessing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// URL handler configuration for custom URL patterns
|
|
||||||
class URLHandler {
|
class URLHandler {
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final List<String> patterns;
|
final List<String> patterns;
|
||||||
@@ -304,7 +299,6 @@ class URLHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a URL matches any of the patterns
|
|
||||||
bool matchesURL(String url) {
|
bool matchesURL(String url) {
|
||||||
if (!enabled || patterns.isEmpty) return false;
|
if (!enabled || patterns.isEmpty) return false;
|
||||||
final lowerUrl = url.toLowerCase();
|
final lowerUrl = url.toLowerCase();
|
||||||
@@ -504,6 +498,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cleanupExtensions({required String reason}) async {
|
Future<void> _cleanupExtensions({required String reason}) async {
|
||||||
|
if (!PlatformBridge.supportsExtensionSystem) {
|
||||||
|
_cleanupInFlight = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.cleanupExtensions();
|
await PlatformBridge.cleanupExtensions();
|
||||||
_log.d('Extensions cleaned up ($reason)');
|
_log.d('Extensions cleaned up ($reason)');
|
||||||
@@ -519,6 +518,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
|
|
||||||
state = state.copyWith(isLoading: true, error: null);
|
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 {
|
try {
|
||||||
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
||||||
await loadExtensions(extensionsDir);
|
await loadExtensions(extensionsDir);
|
||||||
@@ -892,7 +902,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> getAllMetadataProviders() {
|
List<String> getAllMetadataProviders() {
|
||||||
final providers = ['deezer'];
|
final providers = ['deezer', 'qobuz', 'tidal'];
|
||||||
for (final ext in state.extensions) {
|
for (final ext in state.extensions) {
|
||||||
if (ext.enabled && ext.hasMetadataProvider) {
|
if (ext.enabled && ext.hasMetadataProvider) {
|
||||||
providers.add(ext.id);
|
providers.add(ext.id);
|
||||||
@@ -911,8 +921,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.contains('deezer')) {
|
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
|
||||||
result.insert(0, 'deezer');
|
if (!result.contains(provider)) {
|
||||||
|
result.add(provider);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class UserPlaylistCollection {
|
|||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
tracks: tracksRaw
|
tracks: tracksRaw
|
||||||
.whereType<Map>()
|
.whereType<Map<Object?, Object?>>()
|
||||||
.map(
|
.map(
|
||||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||||
)
|
)
|
||||||
@@ -233,19 +233,19 @@ class LibraryCollectionsState {
|
|||||||
|
|
||||||
return LibraryCollectionsState(
|
return LibraryCollectionsState(
|
||||||
wishlist: wishlistRaw
|
wishlist: wishlistRaw
|
||||||
.whereType<Map>()
|
.whereType<Map<Object?, Object?>>()
|
||||||
.map(
|
.map(
|
||||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||||
)
|
)
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
loved: lovedRaw
|
loved: lovedRaw
|
||||||
.whereType<Map>()
|
.whereType<Map<Object?, Object?>>()
|
||||||
.map(
|
.map(
|
||||||
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
(e) => CollectionTrackEntry.fromJson(Map<String, dynamic>.from(e)),
|
||||||
)
|
)
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
playlists: playlistsRaw
|
playlists: playlistsRaw
|
||||||
.whereType<Map>()
|
.whereType<Map<Object?, Object?>>()
|
||||||
.map(
|
.map(
|
||||||
(e) =>
|
(e) =>
|
||||||
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
|
UserPlaylistCollection.fromJson(Map<String, dynamic>.from(e)),
|
||||||
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
|||||||
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
||||||
if (playlist.coverImagePath == destPath) return;
|
if (playlist.coverImagePath == destPath) return;
|
||||||
|
|
||||||
// Copy image to persistent location
|
|
||||||
await File(sourceFilePath).copy(destPath);
|
await File(sourceFilePath).copy(destPath);
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
|||||||
final playlist = state.playlistById(playlistId);
|
final playlist = state.playlistById(playlistId);
|
||||||
if (playlist == null || playlist.coverImagePath == null) return;
|
if (playlist == null || playlist.coverImagePath == null) return;
|
||||||
|
|
||||||
// Delete the file if it exists
|
|
||||||
final path = playlist.coverImagePath;
|
final path = playlist.coverImagePath;
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
|
|||||||
@@ -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/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.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';
|
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||||
|
|
||||||
final _log = AppLogger('LocalLibrary');
|
final _log = AppLogger('LocalLibrary');
|
||||||
|
|
||||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
|
||||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||||
final _prefs = SharedPreferences.getInstance();
|
final _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
final LibraryDatabase _db = LibraryDatabase.instance;
|
final LibraryDatabase _db = LibraryDatabase.instance;
|
||||||
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
final HistoryDatabase _historyDb = HistoryDatabase.instance;
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
static const _progressPollingInterval = Duration(milliseconds: 800);
|
static const _progressPollingInterval = Duration(milliseconds: 1200);
|
||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
Timer? _progressStreamBootstrapTimer;
|
Timer? _progressStreamBootstrapTimer;
|
||||||
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
|
||||||
@@ -165,10 +165,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
var excludedDownloadedCount = 0;
|
var excludedDownloadedCount = 0;
|
||||||
try {
|
try {
|
||||||
final prefs = await prefsFuture;
|
final prefs = await prefsFuture;
|
||||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
lastScannedAt = readLocalLibraryLastScannedAt(prefs);
|
||||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
|
||||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
|
||||||
}
|
|
||||||
excludedDownloadedCount =
|
excludedDownloadedCount =
|
||||||
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -255,8 +252,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
_startProgressPolling();
|
_startProgressPolling();
|
||||||
|
|
||||||
// On iOS, start accessing the security-scoped bookmark so the Go backend
|
|
||||||
// can read files outside the app sandbox.
|
|
||||||
String? resolvedPath;
|
String? resolvedPath;
|
||||||
bool didStartSecurityAccess = false;
|
bool didStartSecurityAccess = false;
|
||||||
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||||
@@ -278,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
try {
|
try {
|
||||||
final isSaf = effectiveFolderPath.startsWith('content://');
|
final isSaf = effectiveFolderPath.startsWith('content://');
|
||||||
|
|
||||||
// Get all file paths from download history to exclude them.
|
|
||||||
// Merge DB + in-memory state to avoid race when a fresh download has not
|
|
||||||
// been flushed to SQLite yet.
|
|
||||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||||
final inMemoryHistoryPaths = ref
|
final inMemoryHistoryPaths = ref
|
||||||
.read(downloadHistoryProvider)
|
.read(downloadHistoryProvider)
|
||||||
@@ -301,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (forceFullScan) {
|
if (forceFullScan) {
|
||||||
// Full scan path - ignores existing data
|
|
||||||
final results = isSaf
|
final results = isSaf
|
||||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||||
@@ -315,7 +306,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
int skippedDownloads = 0;
|
int skippedDownloads = 0;
|
||||||
for (final json in results) {
|
for (final json in results) {
|
||||||
final filePath = json['filePath'] as String?;
|
final filePath = json['filePath'] as String?;
|
||||||
// Skip files that are already in download history
|
|
||||||
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||||
skippedDownloads++;
|
skippedDownloads++;
|
||||||
continue;
|
continue;
|
||||||
@@ -328,16 +318,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Skipped $skippedDownloads files already in download history');
|
_log.i('Skipped $skippedDownloads files already in download history');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full scan should replace library index entirely.
|
await _db.replaceAll(items.map((e) => e.toJson()).toList());
|
||||||
await _db.clearAll();
|
final persistedItems = [...items]..sort(_compareLibraryItems);
|
||||||
if (items.isNotEmpty) {
|
|
||||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||||
_log.d('Saved lastScannedAt: $now');
|
_log.d('Saved lastScannedAt: $now');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -345,7 +332,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
items: items,
|
items: persistedItems,
|
||||||
isScanning: false,
|
isScanning: false,
|
||||||
scanProgress: 100,
|
scanProgress: 100,
|
||||||
lastScannedAt: now,
|
lastScannedAt: now,
|
||||||
@@ -354,16 +341,15 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_log.i(
|
_log.i(
|
||||||
'Full scan complete: ${items.length} tracks found, '
|
'Full scan complete: ${persistedItems.length} tracks found, '
|
||||||
'$skippedDownloads already in downloads',
|
'$skippedDownloads already in downloads',
|
||||||
);
|
);
|
||||||
await _showScanCompleteNotification(
|
await _showScanCompleteNotification(
|
||||||
totalTracks: items.length,
|
totalTracks: persistedItems.length,
|
||||||
excludedDownloadedCount: skippedDownloads,
|
excludedDownloadedCount: skippedDownloads,
|
||||||
errorCount: state.scanErrorCount,
|
errorCount: state.scanErrorCount,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Incremental scan path - only scans new/modified files
|
|
||||||
final existingFiles = await _db.getFileModTimes();
|
final existingFiles = await _db.getFileModTimes();
|
||||||
_log.i(
|
_log.i(
|
||||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||||
@@ -379,18 +365,41 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
|
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use appropriate incremental scan method based on SAF or not
|
final useSnapshotBridge =
|
||||||
final Map<String, dynamic> result;
|
Platform.isAndroid && existingFiles.isNotEmpty;
|
||||||
if (isSaf) {
|
final snapshotPath = useSnapshotBridge
|
||||||
result = await PlatformBridge.scanSafTreeIncremental(
|
? await _db.writeFileModTimesSnapshot()
|
||||||
effectiveFolderPath,
|
: null;
|
||||||
existingFiles,
|
|
||||||
);
|
Map<String, dynamic> result;
|
||||||
} else {
|
try {
|
||||||
result = await PlatformBridge.scanLibraryFolderIncremental(
|
if (isSaf) {
|
||||||
effectiveFolderPath,
|
result = useSnapshotBridge && snapshotPath != null
|
||||||
existingFiles,
|
? 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) {
|
if (_scanCancelRequested) {
|
||||||
@@ -399,8 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse incremental scan result
|
|
||||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
|
||||||
final scannedList =
|
final scannedList =
|
||||||
(result['files'] as List<dynamic>?) ??
|
(result['files'] as List<dynamic>?) ??
|
||||||
(result['scanned'] as List<dynamic>?) ??
|
(result['scanned'] as List<dynamic>?) ??
|
||||||
@@ -421,8 +428,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final existingJson = await _db.getAll();
|
||||||
final currentByPath = <String, LocalLibraryItem>{
|
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>[];
|
final existingDownloadedPaths = <String>[];
|
||||||
currentByPath.removeWhere((path, _) {
|
currentByPath.removeWhere((path, _) {
|
||||||
@@ -439,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert new/modified items (excluding downloaded files)
|
|
||||||
final updatedItems = <LocalLibraryItem>[];
|
final updatedItems = <LocalLibraryItem>[];
|
||||||
int skippedDownloads = existingDownloadedPaths.length;
|
int skippedDownloads = existingDownloadedPaths.length;
|
||||||
if (scannedList.isNotEmpty) {
|
if (scannedList.isNotEmpty) {
|
||||||
@@ -465,7 +473,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removed items
|
|
||||||
if (deletedPaths.isNotEmpty) {
|
if (deletedPaths.isNotEmpty) {
|
||||||
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
final deleteCount = await _db.deleteByPaths(deletedPaths);
|
||||||
for (final path in deletedPaths) {
|
for (final path in deletedPaths) {
|
||||||
@@ -480,7 +487,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||||
_log.d('Saved lastScannedAt: $now');
|
_log.d('Saved lastScannedAt: $now');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -798,7 +805,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_lastScannedAtKey);
|
await clearLocalLibraryLastScannedAt(prefs);
|
||||||
await prefs.remove(_excludedDownloadedCountKey);
|
await prefs.remove(_excludedDownloadedCountKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to clear lastScannedAt: $e');
|
_log.w('Failed to clear lastScannedAt: $e');
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
|
|||||||
|
|
||||||
const _maxRecentItems = 20;
|
const _maxRecentItems = 20;
|
||||||
|
|
||||||
/// Types of items that can be accessed
|
|
||||||
enum RecentAccessType { artist, album, track, playlist }
|
enum RecentAccessType { artist, album, track, playlist }
|
||||||
|
|
||||||
/// Represents a recently accessed item
|
|
||||||
class RecentAccessItem {
|
class RecentAccessItem {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String? subtitle; // Artist name for tracks/albums, null for artists
|
final String? subtitle;
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
final RecentAccessType type;
|
final RecentAccessType type;
|
||||||
final DateTime accessedAt;
|
final DateTime accessedAt;
|
||||||
final String? providerId; // Extension ID or 'deezer' for built-in
|
final String? providerId;
|
||||||
|
|
||||||
const RecentAccessItem({
|
const RecentAccessItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -53,7 +51,6 @@ class RecentAccessItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a unique key for deduplication
|
|
||||||
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -67,10 +64,9 @@ class RecentAccessItem {
|
|||||||
int get hashCode => uniqueKey.hashCode;
|
int get hashCode => uniqueKey.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State for recent access history
|
|
||||||
class RecentAccessState {
|
class RecentAccessState {
|
||||||
final List<RecentAccessItem> items;
|
final List<RecentAccessItem> items;
|
||||||
final Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
|
final Set<String> hiddenDownloadIds;
|
||||||
final bool isLoaded;
|
final bool isLoaded;
|
||||||
|
|
||||||
const RecentAccessState({
|
const RecentAccessState({
|
||||||
@@ -92,7 +88,6 @@ class RecentAccessState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for managing recent access history
|
|
||||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||||
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
||||||
|
|
||||||
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to an artist
|
|
||||||
void recordArtistAccess({
|
void recordArtistAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to an album
|
|
||||||
void recordAlbumAccess({
|
void recordAlbumAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to a track
|
|
||||||
void recordTrackAccess({
|
void recordTrackAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an access to a playlist
|
|
||||||
void recordPlaylistAccess({
|
void recordPlaylistAccess({
|
||||||
required String id,
|
required String id,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a specific item from history
|
|
||||||
void removeItem(RecentAccessItem item) {
|
void removeItem(RecentAccessItem item) {
|
||||||
final updatedItems = state.items
|
final updatedItems = state.items
|
||||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||||
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
|||||||
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hide a download item from recents (without deleting the actual download)
|
|
||||||
void hideDownloadFromRecents(String downloadId) {
|
void hideDownloadFromRecents(String downloadId) {
|
||||||
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||||
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||||
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a download is hidden from recents
|
|
||||||
bool isDownloadHidden(String downloadId) {
|
bool isDownloadHidden(String downloadId) {
|
||||||
return state.hiddenDownloadIds.contains(downloadId);
|
return state.hiddenDownloadIds.contains(downloadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all history
|
|
||||||
void clearHistory() {
|
void clearHistory() {
|
||||||
state = state.copyWith(items: []);
|
state = state.copyWith(items: []);
|
||||||
unawaited(_appStateDb.clearRecentAccessRows());
|
unawaited(_appStateDb.clearRecentAccessRows());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear hidden downloads (show all again)
|
|
||||||
void clearHiddenDownloads() {
|
void clearHiddenDownloads() {
|
||||||
state = state.copyWith(hiddenDownloadIds: {});
|
state = state.copyWith(hiddenDownloadIds: {});
|
||||||
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
const _currentMigrationVersion = 5;
|
const _currentMigrationVersion = 7;
|
||||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||||
final _log = AppLogger('SettingsProvider');
|
final _log = AppLogger('SettingsProvider');
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
class SettingsNotifier extends Notifier<AppSettings> {
|
||||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
|
|
||||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
|
||||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||||
|
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
@@ -34,10 +34,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final prefs = await _prefs;
|
final prefs = await _prefs;
|
||||||
final json = prefs.getString(_settingsKey);
|
final json = prefs.getString(_settingsKey);
|
||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(
|
||||||
|
Map<String, dynamic>.from(jsonDecode(json) as Map),
|
||||||
|
);
|
||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
await _normalizeYouTubeBitratesIfNeeded();
|
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||||
await _normalizeSongLinkRegionIfNeeded();
|
await _normalizeSongLinkRegionIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +52,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _syncLyricsSettingsToBackend() {
|
void _syncLyricsSettingsToBackend() {
|
||||||
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
if (!PlatformBridge.supportsCoreBackend) return;
|
||||||
|
|
||||||
|
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((
|
||||||
|
Object e,
|
||||||
|
) {
|
||||||
_log.w('Failed to sync lyrics providers to backend: $e');
|
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,17 +65,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
'include_romanization_netease': state.lyricsIncludeRomanizationNetease,
|
||||||
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
'multi_person_word_by_word': state.lyricsMultiPersonWordByWord,
|
||||||
'musixmatch_language': state.musixmatchLanguage,
|
'musixmatch_language': state.musixmatchLanguage,
|
||||||
}).catchError((e) {
|
}).catchError((Object e) {
|
||||||
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
_log.w('Failed to sync lyrics fetch options to backend: $e');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncNetworkCompatibilitySettingsToBackend() {
|
void _syncNetworkCompatibilitySettingsToBackend() {
|
||||||
|
if (!PlatformBridge.supportsCoreBackend) return;
|
||||||
|
|
||||||
final compatibilityMode = state.networkCompatibilityMode;
|
final compatibilityMode = state.networkCompatibilityMode;
|
||||||
PlatformBridge.setNetworkCompatibilityOptions(
|
PlatformBridge.setNetworkCompatibilityOptions(
|
||||||
allowHttp: compatibilityMode,
|
allowHttp: compatibilityMode,
|
||||||
insecureTls: compatibilityMode,
|
insecureTls: compatibilityMode,
|
||||||
).catchError((e) {
|
).catchError((Object e) {
|
||||||
_log.w('Failed to sync network compatibility options to backend: $e');
|
_log.w('Failed to sync network compatibility options to backend: $e');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -115,6 +123,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||||
|
// Migration 7: YouTube is no longer a built-in service — reset to Tidal
|
||||||
|
if (state.defaultService == 'youtube') {
|
||||||
|
state = state.copyWith(defaultService: 'tidal');
|
||||||
|
}
|
||||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
@@ -146,46 +158,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int _nearestSupportedBitrate(int value, List<int> supported) {
|
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||||
var nearest = supported.first;
|
if (!Platform.isIOS) return;
|
||||||
var nearestDistance = (value - nearest).abs();
|
|
||||||
|
|
||||||
for (final option in supported.skip(1)) {
|
final currentDir = state.downloadDirectory.trim();
|
||||||
final distance = (value - option).abs();
|
if (currentDir.isEmpty) return;
|
||||||
// On tie, prefer higher quality bitrate.
|
|
||||||
if (distance < nearestDistance ||
|
|
||||||
(distance == nearestDistance && option > nearest)) {
|
|
||||||
nearest = option;
|
|
||||||
nearestDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nearest;
|
final normalizedDir = await validateOrFixIosPath(currentDir);
|
||||||
}
|
if (normalizedDir == currentDir) return;
|
||||||
|
|
||||||
int _normalizeYouTubeOpusBitrate(int bitrate) {
|
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
|
||||||
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
|
state = state.copyWith(downloadDirectory: normalizedDir);
|
||||||
}
|
|
||||||
|
|
||||||
int _normalizeYouTubeMp3Bitrate(int bitrate) {
|
|
||||||
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _normalizeYouTubeBitratesIfNeeded() async {
|
|
||||||
final normalizedOpus = _normalizeYouTubeOpusBitrate(
|
|
||||||
state.youtubeOpusBitrate,
|
|
||||||
);
|
|
||||||
final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate);
|
|
||||||
|
|
||||||
if (normalizedOpus == state.youtubeOpusBitrate &&
|
|
||||||
normalizedMp3 == state.youtubeMp3Bitrate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
youtubeOpusBitrate: normalizedOpus,
|
|
||||||
youtubeMp3Bitrate: normalizedMp3,
|
|
||||||
);
|
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +337,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setCreatePlaylistFolder(bool enabled) {
|
||||||
|
state = state.copyWith(createPlaylistFolder: enabled);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setUseAlbumArtistForFolders(bool enabled) {
|
void setUseAlbumArtistForFolders(bool enabled) {
|
||||||
state = state.copyWith(useAlbumArtistForFolders: enabled);
|
state = state.copyWith(useAlbumArtistForFolders: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -385,8 +373,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setMetadataSource(String source) {
|
void setMetadataSource(String source) {
|
||||||
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
|
state = state.copyWith(metadataSource: source);
|
||||||
state = state.copyWith(metadataSource: normalized);
|
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +386,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_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) {
|
void setEnableLogging(bool enabled) {
|
||||||
state = state.copyWith(enableLogging: enabled);
|
state = state.copyWith(enableLogging: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -435,18 +431,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setYoutubeOpusBitrate(int bitrate) {
|
|
||||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
|
||||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setYoutubeMp3Bitrate(int bitrate) {
|
|
||||||
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
|
|
||||||
state = state.copyWith(youtubeMp3Bitrate: normalized);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setUseAllFilesAccess(bool enabled) {
|
void setUseAllFilesAccess(bool enabled) {
|
||||||
state = state.copyWith(useAllFilesAccess: enabled);
|
state = state.copyWith(useAllFilesAccess: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -502,6 +486,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setLocalLibraryAutoScan(String mode) {
|
||||||
|
state = state.copyWith(localLibraryAutoScan: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setTutorialComplete() {
|
void setTutorialComplete() {
|
||||||
state = state.copyWith(hasCompletedTutorial: true);
|
state = state.copyWith(hasCompletedTutorial: true);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
@@ -6,17 +7,18 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
|||||||
|
|
||||||
final _log = AppLogger('StoreProvider');
|
final _log = AppLogger('StoreProvider');
|
||||||
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
final RegExp _leadingVersionPrefix = RegExp(r'^v');
|
||||||
|
const _registryUrlPrefKey = 'store_registry_url';
|
||||||
|
|
||||||
int compareVersions(String v1, String v2) {
|
int compareVersions(String v1, String v2) {
|
||||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||||
|
|
||||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||||
|
|
||||||
for (var i = 0; i < maxLen; i++) {
|
for (var i = 0; i < maxLen; i++) {
|
||||||
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
|
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
|
||||||
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
|
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
|
||||||
|
|
||||||
if (n1 < n2) return -1;
|
if (n1 < n2) return -1;
|
||||||
if (n1 > n2) return 1;
|
if (n1 > n2) return 1;
|
||||||
}
|
}
|
||||||
@@ -24,14 +26,19 @@ int compareVersions(String v1, String v2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StoreCategory {
|
class StoreCategory {
|
||||||
|
|
||||||
static const String metadata = 'metadata';
|
static const String metadata = 'metadata';
|
||||||
static const String download = 'download';
|
static const String download = 'download';
|
||||||
static const String utility = 'utility';
|
static const String utility = 'utility';
|
||||||
static const String lyrics = 'lyrics';
|
static const String lyrics = 'lyrics';
|
||||||
static const String integration = 'integration';
|
static const String integration = 'integration';
|
||||||
|
|
||||||
static const List<String> all = [metadata, download, utility, lyrics, integration];
|
static const List<String> all = [
|
||||||
|
metadata,
|
||||||
|
download,
|
||||||
|
utility,
|
||||||
|
lyrics,
|
||||||
|
integration,
|
||||||
|
];
|
||||||
|
|
||||||
static String getDisplayName(String category) {
|
static String getDisplayName(String category) {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
@@ -92,7 +99,8 @@ class StoreExtension {
|
|||||||
return StoreExtension(
|
return StoreExtension(
|
||||||
id: json['id'] as String? ?? '',
|
id: json['id'] as String? ?? '',
|
||||||
name: json['name'] as String? ?? '',
|
name: json['name'] as String? ?? '',
|
||||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
displayName:
|
||||||
|
json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||||
version: json['version'] as String? ?? '0.0.0',
|
version: json['version'] as String? ?? '0.0.0',
|
||||||
author: json['author'] as String? ?? 'Unknown',
|
author: json['author'] as String? ?? 'Unknown',
|
||||||
description: json['description'] as String? ?? '',
|
description: json['description'] as String? ?? '',
|
||||||
@@ -115,7 +123,6 @@ class StoreExtension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class StoreState {
|
class StoreState {
|
||||||
final List<StoreExtension> extensions;
|
final List<StoreExtension> extensions;
|
||||||
final String? selectedCategory;
|
final String? selectedCategory;
|
||||||
@@ -125,6 +132,7 @@ class StoreState {
|
|||||||
final String? downloadingId;
|
final String? downloadingId;
|
||||||
final String? error;
|
final String? error;
|
||||||
final bool isInitialized;
|
final bool isInitialized;
|
||||||
|
final String registryUrl;
|
||||||
|
|
||||||
const StoreState({
|
const StoreState({
|
||||||
this.extensions = const [],
|
this.extensions = const [],
|
||||||
@@ -135,8 +143,12 @@ class StoreState {
|
|||||||
this.downloadingId,
|
this.downloadingId,
|
||||||
this.error,
|
this.error,
|
||||||
this.isInitialized = false,
|
this.isInitialized = false,
|
||||||
|
this.registryUrl = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Whether a registry URL has been configured by the user.
|
||||||
|
bool get hasRegistryUrl => registryUrl.isNotEmpty;
|
||||||
|
|
||||||
StoreState copyWith({
|
StoreState copyWith({
|
||||||
List<StoreExtension>? extensions,
|
List<StoreExtension>? extensions,
|
||||||
String? selectedCategory,
|
String? selectedCategory,
|
||||||
@@ -149,16 +161,22 @@ class StoreState {
|
|||||||
String? error,
|
String? error,
|
||||||
bool clearError = false,
|
bool clearError = false,
|
||||||
bool? isInitialized,
|
bool? isInitialized,
|
||||||
|
String? registryUrl,
|
||||||
}) {
|
}) {
|
||||||
return StoreState(
|
return StoreState(
|
||||||
extensions: extensions ?? this.extensions,
|
extensions: extensions ?? this.extensions,
|
||||||
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
|
selectedCategory: clearCategory
|
||||||
|
? null
|
||||||
|
: (selectedCategory ?? this.selectedCategory),
|
||||||
searchQuery: searchQuery ?? this.searchQuery,
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
isDownloading: isDownloading ?? this.isDownloading,
|
isDownloading: isDownloading ?? this.isDownloading,
|
||||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
downloadingId: clearDownloadingId
|
||||||
|
? null
|
||||||
|
: (downloadingId ?? this.downloadingId),
|
||||||
error: clearError ? null : (error ?? this.error),
|
error: clearError ? null : (error ?? this.error),
|
||||||
isInitialized: isInitialized ?? this.isInitialized,
|
isInitialized: isInitialized ?? this.isInitialized,
|
||||||
|
registryUrl: registryUrl ?? this.registryUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,13 +189,16 @@ class StoreState {
|
|||||||
|
|
||||||
if (searchQuery.isNotEmpty) {
|
if (searchQuery.isNotEmpty) {
|
||||||
final query = searchQuery.toLowerCase();
|
final query = searchQuery.toLowerCase();
|
||||||
result = result.where((e) =>
|
result = result
|
||||||
e.name.toLowerCase().contains(query) ||
|
.where(
|
||||||
e.displayName.toLowerCase().contains(query) ||
|
(e) =>
|
||||||
e.description.toLowerCase().contains(query) ||
|
e.name.toLowerCase().contains(query) ||
|
||||||
e.author.toLowerCase().contains(query) ||
|
e.displayName.toLowerCase().contains(query) ||
|
||||||
e.tags.any((t) => t.toLowerCase().contains(query))
|
e.description.toLowerCase().contains(query) ||
|
||||||
).toList();
|
e.author.toLowerCase().contains(query) ||
|
||||||
|
e.tags.any((t) => t.toLowerCase().contains(query)),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -197,24 +218,99 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
Future<void> initialize(String cacheDir) async {
|
Future<void> initialize(String cacheDir) async {
|
||||||
if (state.isInitialized) return;
|
if (state.isInitialized) return;
|
||||||
|
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
// Load saved registry URL early to avoid UI flash (empty → setup screen)
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoading: true,
|
||||||
|
clearError: true,
|
||||||
|
registryUrl: savedUrl,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.initExtensionStore(cacheDir);
|
await PlatformBridge.initExtensionStore(cacheDir);
|
||||||
await refresh();
|
|
||||||
|
if (savedUrl.isNotEmpty) {
|
||||||
|
await PlatformBridge.setStoreRegistryUrl(savedUrl);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
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) {
|
} catch (e) {
|
||||||
_log.e('Failed to initialize store: $e');
|
_log.e('Failed to initialize store: $e');
|
||||||
state = state.copyWith(isLoading: false, error: e.toString());
|
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();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
registryUrl: resolvedUrl,
|
||||||
|
extensions: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
_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 {
|
Future<void> refresh({bool forceRefresh = false}) async {
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
|
final extensions = await PlatformBridge.getStoreExtensions(
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -242,12 +338,23 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
Future<bool> installExtension(
|
||||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
String extensionId,
|
||||||
|
String tempDir,
|
||||||
|
String extensionsDir,
|
||||||
|
) async {
|
||||||
|
state = state.copyWith(
|
||||||
|
isDownloading: true,
|
||||||
|
downloadingId: extensionId,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_log.i('Downloading extension: $extensionId');
|
_log.i('Downloading extension: $extensionId');
|
||||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||||
|
extensionId,
|
||||||
|
tempDir,
|
||||||
|
);
|
||||||
|
|
||||||
_log.i('Installing extension from: $downloadPath');
|
_log.i('Installing extension from: $downloadPath');
|
||||||
final extNotifier = ref.read(extensionProvider.notifier);
|
final extNotifier = ref.read(extensionProvider.notifier);
|
||||||
@@ -262,18 +369,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to install extension: $e');
|
_log.e('Failed to install extension: $e');
|
||||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
state = state.copyWith(
|
||||||
|
isDownloading: false,
|
||||||
|
clearDownloadingId: true,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
state = state.copyWith(
|
||||||
|
isDownloading: true,
|
||||||
|
downloadingId: extensionId,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_log.i('Downloading update for: $extensionId');
|
_log.i('Downloading update for: $extensionId');
|
||||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||||
|
extensionId,
|
||||||
|
tempDir,
|
||||||
|
);
|
||||||
|
|
||||||
_log.i('Upgrading extension from: $downloadPath');
|
_log.i('Upgrading extension from: $downloadPath');
|
||||||
final extNotifier = ref.read(extensionProvider.notifier);
|
final extNotifier = ref.read(extensionProvider.notifier);
|
||||||
@@ -288,7 +405,11 @@ class StoreNotifier extends Notifier<StoreState> {
|
|||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to update extension: $e');
|
_log.e('Failed to update extension: $e');
|
||||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
state = state.copyWith(
|
||||||
|
isDownloading: false,
|
||||||
|
clearDownloadingId: true,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set custom seed color (used when dynamic color is disabled)
|
|
||||||
Future<void> setSeedColor(Color color) async {
|
Future<void> setSeedColor(Color color) async {
|
||||||
state = state.copyWith(seedColorValue: color.toARGB32());
|
state = state.copyWith(seedColorValue: color.toARGB32());
|
||||||
await _saveToStorage();
|
await _saveToStorage();
|
||||||
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+263
-246
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
@@ -17,19 +18,18 @@ class TrackState {
|
|||||||
final String? artistId;
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String? headerImageUrl; // Artist header image for background
|
final String? headerImageUrl;
|
||||||
final int? monthlyListeners; // Artist monthly listeners
|
final int? monthlyListeners;
|
||||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
final List<ArtistAlbum>? artistAlbums;
|
||||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
final List<Track>? artistTopTracks;
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists;
|
||||||
final List<SearchAlbum>? searchAlbums; // For search results (albums)
|
final List<SearchAlbum>? searchAlbums;
|
||||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
final List<SearchPlaylist>? searchPlaylists;
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText;
|
||||||
final bool isShowingRecentAccess; // For recent access mode
|
final bool isShowingRecentAccess;
|
||||||
final String?
|
final String? searchExtensionId;
|
||||||
searchExtensionId; // Extension ID used for current search results
|
final String? selectedSearchFilter;
|
||||||
final String?
|
final String? searchSource;
|
||||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
this.tracks = const [],
|
this.tracks = const [],
|
||||||
@@ -52,6 +52,7 @@ class TrackState {
|
|||||||
this.isShowingRecentAccess = false,
|
this.isShowingRecentAccess = false,
|
||||||
this.searchExtensionId,
|
this.searchExtensionId,
|
||||||
this.selectedSearchFilter,
|
this.selectedSearchFilter,
|
||||||
|
this.searchSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get hasContent =>
|
bool get hasContent =>
|
||||||
@@ -83,6 +84,8 @@ class TrackState {
|
|||||||
String? searchExtensionId,
|
String? searchExtensionId,
|
||||||
String? selectedSearchFilter,
|
String? selectedSearchFilter,
|
||||||
bool clearSelectedSearchFilter = false,
|
bool clearSelectedSearchFilter = false,
|
||||||
|
String? searchSource,
|
||||||
|
bool clearSearchSource = false,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
tracks: tracks ?? this.tracks,
|
tracks: tracks ?? this.tracks,
|
||||||
@@ -108,6 +111,9 @@ class TrackState {
|
|||||||
selectedSearchFilter: clearSelectedSearchFilter
|
selectedSearchFilter: clearSelectedSearchFilter
|
||||||
? null
|
? null
|
||||||
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||||
|
searchSource: clearSearchSource
|
||||||
|
? null
|
||||||
|
: (searchSource ?? this.searchSource),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,9 +124,9 @@ class ArtistAlbum {
|
|||||||
final String releaseDate;
|
final String releaseDate;
|
||||||
final int totalTracks;
|
final int totalTracks;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String albumType; // album, single, compilation
|
final String albumType;
|
||||||
final String artists;
|
final String artists;
|
||||||
final String? providerId; // Extension ID if from extension
|
final String? providerId;
|
||||||
|
|
||||||
const ArtistAlbum({
|
const ArtistAlbum({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -195,7 +201,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return const TrackState();
|
return const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if request is still valid (not cancelled by newer request)
|
|
||||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
@@ -208,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
if (extensionHandler != null) {
|
if (extensionHandler != null) {
|
||||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||||
|
|
||||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
|
||||||
Map<String, dynamic>? result;
|
Map<String, dynamic>? result;
|
||||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||||
result = await PlatformBridge.handleURLWithExtension(url);
|
result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
@@ -230,7 +234,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < 3) {
|
if (attempt < 3) {
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,14 +275,18 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumId: result['album']?['id'] as String?,
|
albumId:
|
||||||
|
(result['album'] as Map<String, dynamic>?)?['id'] as String?,
|
||||||
albumName:
|
albumName:
|
||||||
result['name'] as String? ??
|
result['name'] as String? ??
|
||||||
result['album']?['name'] as String?,
|
(result['album'] as Map<String, dynamic>?)?['name']
|
||||||
|
as String?,
|
||||||
playlistName: type == 'playlist'
|
playlistName: type == 'playlist'
|
||||||
? result['name'] as String?
|
? result['name'] as String?
|
||||||
: null,
|
: null,
|
||||||
coverUrl: result['cover_url'] as String?,
|
coverUrl: normalizeCoverReference(
|
||||||
|
result['cover_url']?.toString(),
|
||||||
|
),
|
||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -305,10 +313,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
artistId: artistData['id'] as String?,
|
artistId: artistData['id'] as String?,
|
||||||
artistName: artistData['name'] as String?,
|
artistName: artistData['name'] as String?,
|
||||||
coverUrl:
|
coverUrl: normalizeRemoteHttpUrl(
|
||||||
artistData['image_url'] as String? ??
|
(artistData['image_url'] ?? artistData['images'])?.toString(),
|
||||||
artistData['images'] as String?,
|
),
|
||||||
headerImageUrl: artistData['header_image'] as String?,
|
headerImageUrl: normalizeRemoteHttpUrl(
|
||||||
|
artistData['header_image']?.toString(),
|
||||||
|
),
|
||||||
monthlyListeners: artistData['listeners'] as int?,
|
monthlyListeners: artistData['listeners'] as int?,
|
||||||
artistAlbums: albums,
|
artistAlbums: albums,
|
||||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||||
@@ -349,7 +359,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
albumId: id,
|
albumId: id,
|
||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||||
);
|
);
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
@@ -363,7 +373,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
playlistName: playlistInfo['name'] as String?,
|
playlistName: playlistInfo['name'] as String?,
|
||||||
coverUrl: playlistInfo['images'] as String?,
|
coverUrl: normalizeRemoteHttpUrl(
|
||||||
|
playlistInfo['images']?.toString(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
@@ -377,7 +389,78 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
artistId: artistInfo['id'] as String?,
|
artistId: artistInfo['id'] as String?,
|
||||||
artistName: artistInfo['name'] 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,
|
artistAlbums: albums,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -392,151 +475,74 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
final id = parsed['id'] 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') {
|
if (type == 'track') {
|
||||||
try {
|
final trackData = metadata['track'] as Map<String, dynamic>;
|
||||||
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
|
final track = _parseTrack(trackData);
|
||||||
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
|
state = TrackState(
|
||||||
url,
|
tracks: [track],
|
||||||
);
|
isLoading: false,
|
||||||
if (!_isRequestValid(requestId)) return;
|
coverUrl: track.coverUrl,
|
||||||
|
);
|
||||||
final spotifyUrl = conversion['spotify_url'] as String?;
|
} else if (type == 'album') {
|
||||||
final deezerUrl = conversion['deezer_url'] as String?;
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
|
final tracks = trackList
|
||||||
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
final metadata =
|
.toList();
|
||||||
await PlatformBridge.getSpotifyMetadataWithFallback(
|
state = TrackState(
|
||||||
spotifyUrl,
|
tracks: tracks,
|
||||||
);
|
isLoading: false,
|
||||||
if (!_isRequestValid(requestId)) return;
|
albumId: 'tidal:$id',
|
||||||
|
albumName: albumInfo['name'] as String?,
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||||
final track = _parseTrack(trackData);
|
);
|
||||||
state = TrackState(
|
_preWarmCacheForTracks(tracks);
|
||||||
tracks: [track],
|
} else if (type == 'playlist') {
|
||||||
isLoading: false,
|
final playlistInfo =
|
||||||
coverUrl: track.coverUrl,
|
metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
);
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
return;
|
final tracks = trackList
|
||||||
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
|
.toList();
|
||||||
final deezerParsed = await PlatformBridge.parseDeezerUrl(
|
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||||
deezerUrl,
|
final playlistName =
|
||||||
);
|
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||||
final metadata = await PlatformBridge.getDeezerMetadata(
|
final coverUrl = normalizeRemoteHttpUrl(
|
||||||
'track',
|
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||||
deezerParsed['id'] as String,
|
);
|
||||||
);
|
state = TrackState(
|
||||||
if (!_isRequestValid(requestId)) return;
|
tracks: tracks,
|
||||||
|
isLoading: false,
|
||||||
final trackData = metadata['track'] as Map<String, dynamic>;
|
playlistName: playlistName,
|
||||||
final track = _parseTrack(trackData);
|
coverUrl: coverUrl,
|
||||||
state = TrackState(
|
);
|
||||||
tracks: [track],
|
_preWarmCacheForTracks(tracks);
|
||||||
isLoading: false,
|
} else if (type == 'artist') {
|
||||||
coverUrl: track.coverUrl,
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
);
|
final albumsList = metadata['albums'] as List<dynamic>;
|
||||||
return;
|
final albums = albumsList
|
||||||
}
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
} catch (e) {
|
.toList();
|
||||||
_log.w('Failed to convert Tidal URL via SongLink: $e');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If URL doesn't match any known service, it's unrecognized
|
state = TrackState(
|
||||||
final isSpotifyUrl =
|
isLoading: false,
|
||||||
url.contains('open.spotify.com') ||
|
error: 'url_not_recognized',
|
||||||
url.contains('spotify.link') ||
|
hasSearchText: state.hasSearchText,
|
||||||
url.startsWith('spotify:');
|
);
|
||||||
if (!isSpotifyUrl) {
|
|
||||||
state = TrackState(
|
|
||||||
isLoading: false,
|
|
||||||
error: 'url_not_recognized',
|
|
||||||
hasSearchText: state.hasSearchText,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
|
||||||
if (!_isRequestValid(requestId)) return;
|
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
|
||||||
|
|
||||||
Map<String, dynamic> metadata;
|
|
||||||
|
|
||||||
try {
|
|
||||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
|
||||||
} catch (e) {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
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: parsed['id'] as String?,
|
|
||||||
albumName: albumInfo['name'] as String?,
|
|
||||||
coverUrl: albumInfo['images'] as String?,
|
|
||||||
);
|
|
||||||
_preWarmCacheForTracks(tracks);
|
|
||||||
} else if (type == 'playlist') {
|
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
|
||||||
final tracks = trackList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
|
||||||
state = TrackState(
|
|
||||||
tracks: tracks,
|
|
||||||
isLoading: false,
|
|
||||||
playlistName: owner?['name'] as String?,
|
|
||||||
coverUrl: owner?['images'] as String?,
|
|
||||||
);
|
|
||||||
_preWarmCacheForTracks(tracks);
|
|
||||||
} else if (type == 'artist') {
|
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
|
||||||
final albums = albumsList
|
|
||||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
state = TrackState(
|
|
||||||
tracks: [],
|
|
||||||
isLoading: false,
|
|
||||||
artistId: artistInfo['id'] as String?,
|
|
||||||
artistName: artistInfo['name'] as String?,
|
|
||||||
coverUrl: artistInfo['images'] as String?,
|
|
||||||
artistAlbums: albums,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -547,10 +553,13 @@ 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;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve selected filter during loading
|
|
||||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
@@ -566,52 +575,68 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||||
(e) => e.enabled && e.hasMetadataProvider,
|
(e) => e.enabled && e.hasMetadataProvider,
|
||||||
);
|
);
|
||||||
final searchProvider = settings.searchProvider;
|
final includeExtensions =
|
||||||
final useExtensions =
|
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||||
settings.useExtensionProviders &&
|
|
||||||
hasActiveMetadataExtensions &&
|
|
||||||
searchProvider != null &&
|
|
||||||
searchProvider.isNotEmpty;
|
|
||||||
|
|
||||||
const source = 'deezer';
|
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||||
|
|
||||||
_log.i(
|
_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;
|
Map<String, dynamic> results;
|
||||||
List<Track> extensionTracks = [];
|
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||||
|
|
||||||
if (useExtensions) {
|
if (effectiveProvider == 'deezer') {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling extension search API...');
|
_log.d('Calling metadata provider search API...');
|
||||||
final extResults = await PlatformBridge.searchTracksWithExtensions(
|
metadataTrackResults =
|
||||||
query,
|
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||||
limit: 20,
|
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) {
|
} 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...');
|
switch (effectiveProvider) {
|
||||||
results = await PlatformBridge.searchDeezerAll(
|
case 'tidal':
|
||||||
query,
|
_log.d('Calling Tidal search API...');
|
||||||
trackLimit: 20,
|
results = await PlatformBridge.searchTidalAll(
|
||||||
artistLimit: 2,
|
query,
|
||||||
filter: currentFilter,
|
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(
|
_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)) {
|
if (!_isRequestValid(requestId)) {
|
||||||
@@ -622,32 +647,20 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
final albumList = results['albums'] as List<dynamic>? ?? [];
|
final albumList = results['albums'] as List<dynamic>? ?? [];
|
||||||
|
final trackSearchResults = metadataTrackResults.isNotEmpty
|
||||||
|
? metadataTrackResults
|
||||||
|
: trackList.whereType<Map<String, dynamic>>().toList();
|
||||||
|
|
||||||
_log.d(
|
_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>[];
|
final tracks = <Track>[];
|
||||||
|
|
||||||
tracks.addAll(extensionTracks);
|
for (int i = 0; i < trackSearchResults.length; i++) {
|
||||||
|
final t = trackSearchResults[i];
|
||||||
final existingIsrcs = extensionTracks
|
|
||||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
|
||||||
.map((t) => t.isrc!)
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
for (int i = 0; i < trackList.length; i++) {
|
|
||||||
final t = trackList[i];
|
|
||||||
try {
|
try {
|
||||||
if (t is Map<String, dynamic>) {
|
tracks.add(_parseSearchTrack(t));
|
||||||
final track = _parseSearchTrack(t);
|
|
||||||
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tracks.add(track);
|
|
||||||
} else {
|
|
||||||
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.e('Failed to parse track[$i]: $e', e);
|
_log.e('Failed to parse track[$i]: $e', e);
|
||||||
}
|
}
|
||||||
@@ -697,7 +710,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_log.i(
|
_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(
|
state = TrackState(
|
||||||
@@ -708,7 +721,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
selectedSearchFilter: currentFilter,
|
||||||
|
searchSource: effectiveProvider,
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
@@ -734,8 +748,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
selectedSearchFilter:
|
selectedSearchFilter: state.selectedSearchFilter,
|
||||||
state.selectedSearchFilter, // Preserve filter during loading
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -774,9 +787,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||||
searchExtensionId: extensionId, // Store which extension was used
|
searchExtensionId: extensionId,
|
||||||
selectedSearchFilter:
|
selectedSearchFilter: state.selectedSearchFilter,
|
||||||
state.selectedSearchFilter, // Preserve selected filter
|
|
||||||
);
|
);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return;
|
if (!_isRequestValid(requestId)) return;
|
||||||
@@ -831,16 +843,13 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final tracks = List<Track>.from(state.tracks);
|
final tracks = List<Track>.from(state.tracks);
|
||||||
tracks[index] = updatedTrack;
|
tracks[index] = updatedTrack;
|
||||||
state = state.copyWith(tracks: tracks);
|
state = state.copyWith(tracks: tracks);
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// Silently ignore update failures - track may have been removed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
state = const TrackState();
|
state = const TrackState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set selected search filter for extension search
|
|
||||||
void setSearchFilter(String? filter) {
|
void setSearchFilter(String? filter) {
|
||||||
if (state.selectedSearchFilter == filter) return;
|
if (state.selectedSearchFilter == filter) return;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
@@ -849,7 +858,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set search text state for back button handling
|
|
||||||
void setSearchText(bool hasText) {
|
void setSearchText(bool hasText) {
|
||||||
if (state.hasSearchText == hasText) {
|
if (state.hasSearchText == hasText) {
|
||||||
return;
|
return;
|
||||||
@@ -864,7 +872,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = state.copyWith(isShowingRecentAccess: showing);
|
state = state.copyWith(isShowingRecentAccess: showing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set tracks from a collection (album/playlist) opened from search results
|
|
||||||
void setTracksFromCollection({
|
void setTracksFromCollection({
|
||||||
required List<Track> tracks,
|
required List<Track> tracks,
|
||||||
String? albumName,
|
String? albumName,
|
||||||
@@ -884,15 +891,17 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
Track _parseTrack(Map<String, dynamic> data) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
final durationMs = _extractDurationMs(data);
|
final durationMs = _extractDurationMs(data);
|
||||||
|
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||||
|
final nativeId = (data['id'] ?? '').toString();
|
||||||
return Track(
|
return Track(
|
||||||
id: data['spotify_id'] as String? ?? '',
|
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
|
||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
artistName: data['artists'] as String? ?? '',
|
artistName: data['artists'] as String? ?? '',
|
||||||
albumName: data['album_name'] as String? ?? '',
|
albumName: data['album_name'] as String? ?? '',
|
||||||
albumArtist: data['album_artist'] as String?,
|
albumArtist: data['album_artist'] as String?,
|
||||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||||
albumId: data['album_id']?.toString(),
|
albumId: data['album_id']?.toString(),
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
@@ -907,26 +916,32 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final durationMs = _extractDurationMs(data);
|
final durationMs = _extractDurationMs(data);
|
||||||
|
|
||||||
final itemType = data['item_type']?.toString();
|
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(
|
return Track(
|
||||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
id: preferredId,
|
||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||||
albumArtist: data['album_artist']?.toString(),
|
albumArtist: data['album_artist']?.toString(),
|
||||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||||
albumId: data['album_id']?.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(),
|
isrc: data['isrc']?.toString(),
|
||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
discNumber: data['disc_number'] as int?,
|
discNumber: data['disc_number'] as int?,
|
||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
totalTracks: data['total_tracks'] as int?,
|
totalTracks: data['total_tracks'] as int?,
|
||||||
source:
|
source: effectiveSource,
|
||||||
source ??
|
|
||||||
data['source']?.toString() ??
|
|
||||||
data['provider_id']?.toString(),
|
|
||||||
albumType: data['album_type']?.toString(),
|
albumType: data['album_type']?.toString(),
|
||||||
itemType: itemType,
|
itemType: itemType,
|
||||||
);
|
);
|
||||||
@@ -964,7 +979,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
releaseDate: data['release_date'] as String? ?? '',
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
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',
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
artists: data['artists'] as String? ?? '',
|
artists: data['artists'] as String? ?? '',
|
||||||
providerId: data['provider_id']?.toString(),
|
providerId: data['provider_id']?.toString(),
|
||||||
@@ -975,7 +992,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
return SearchArtist(
|
return SearchArtist(
|
||||||
id: data['id'] as String? ?? '',
|
id: data['id'] as String? ?? '',
|
||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
imageUrl: data['images'] as String?,
|
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||||
followers: data['followers'] as int? ?? 0,
|
followers: data['followers'] as int? ?? 0,
|
||||||
popularity: data['popularity'] as int? ?? 0,
|
popularity: data['popularity'] as int? ?? 0,
|
||||||
);
|
);
|
||||||
@@ -986,7 +1003,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
id: data['id'] as String? ?? '',
|
id: data['id'] as String? ?? '',
|
||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
artists: data['artists'] as String? ?? '',
|
artists: data['artists'] as String? ?? '',
|
||||||
imageUrl: data['images'] as String?,
|
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||||
releaseDate: data['release_date'] as String?,
|
releaseDate: data['release_date'] as String?,
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
albumType: data['album_type'] as String? ?? 'album',
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
@@ -998,7 +1015,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
id: data['id'] as String? ?? '',
|
id: data['id'] as String? ?? '',
|
||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
owner: data['owner'] as String? ?? '',
|
owner: data['owner'] as String? ?? '',
|
||||||
imageUrl: data['images'] as String?,
|
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1015,7 +1032,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
'track_name': track.name,
|
'track_name': track.name,
|
||||||
'artist_name': track.artistName,
|
'artist_name': track.artistName,
|
||||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
'spotify_id': track.id,
|
||||||
'service': 'tidal',
|
'service': 'tidal',
|
||||||
});
|
});
|
||||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||||
|
|||||||
+200
-52
@@ -11,8 +11,10 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.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/track_collection_quick_actions.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
@@ -81,16 +83,23 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Use extensionId if available, otherwise detect from albumId prefix
|
|
||||||
final providerId =
|
final providerId =
|
||||||
widget.extensionId ??
|
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
|
ref
|
||||||
.read(recentAccessProvider.notifier)
|
.read(recentAccessProvider.notifier)
|
||||||
.recordAlbumAccess(
|
.recordAlbumAccess(
|
||||||
id: widget.albumId,
|
id: widget.albumId,
|
||||||
name: widget.albumName,
|
name: widget.albumName,
|
||||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
artistName:
|
||||||
|
widget.artistName ??
|
||||||
|
widget.tracks?.firstOrNull?.albumArtist ??
|
||||||
|
widget.tracks?.firstOrNull?.artistName,
|
||||||
imageUrl: widget.coverUrl,
|
imageUrl: widget.coverUrl,
|
||||||
providerId: providerId,
|
providerId: providerId,
|
||||||
);
|
);
|
||||||
@@ -129,9 +138,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
/// Upgrade cover URL to a higher 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).
|
|
||||||
String? _highResCoverUrl(String? url) {
|
String? _highResCoverUrl(String? url) {
|
||||||
if (url == null) return null;
|
if (url == null) return null;
|
||||||
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
||||||
@@ -167,36 +174,107 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> metadata;
|
|
||||||
|
|
||||||
if (widget.albumId.startsWith('deezer:')) {
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
metadata = await PlatformBridge.getDeezerMetadata(
|
final metadata = await PlatformBridge.getDeezerMetadata(
|
||||||
'album',
|
'album',
|
||||||
deezerAlbumId,
|
deezerAlbumId,
|
||||||
);
|
);
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
|
?.toString();
|
||||||
|
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_artistId = artistId;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (widget.albumId.startsWith('qobuz:')) {
|
||||||
|
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
|
||||||
|
final metadata = await PlatformBridge.getQobuzMetadata(
|
||||||
|
'album',
|
||||||
|
qobuzAlbumId,
|
||||||
|
);
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
|
?.toString();
|
||||||
|
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_artistId = artistId;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (widget.albumId.startsWith('tidal:')) {
|
||||||
|
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
|
||||||
|
final metadata = await PlatformBridge.getTidalMetadata(
|
||||||
|
'album',
|
||||||
|
tidalAlbumId,
|
||||||
|
);
|
||||||
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
|
final tracks = trackList
|
||||||
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
||||||
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
|
?.toString();
|
||||||
|
|
||||||
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = tracks;
|
||||||
|
_artistId = artistId;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
}
|
if (result == null || result['tracks'] == null) {
|
||||||
|
throw StateError('Failed to load album metadata from extension');
|
||||||
|
}
|
||||||
|
|
||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = result['tracks'] as List<dynamic>;
|
||||||
final tracks = trackList
|
final tracks = trackList
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
|
final albumInfo = result['album'] as Map<String, dynamic>?;
|
||||||
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
||||||
?.toString();
|
?.toString();
|
||||||
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_tracks = tracks;
|
_tracks = tracks;
|
||||||
_artistId = artistId;
|
_artistId = artistId;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -218,7 +296,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
artistId:
|
artistId:
|
||||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||||
coverUrl: data['images'] as String?,
|
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||||
isrc: data['isrc'] as String?,
|
isrc: data['isrc'] as String?,
|
||||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
@@ -229,6 +307,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _recommendedDownloadService() {
|
||||||
|
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||||
|
return widget.extensionId;
|
||||||
|
}
|
||||||
|
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||||
|
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||||
|
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -245,8 +333,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(32),
|
padding: EdgeInsets.all(16),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: AlbumTrackListSkeleton(itemCount: 10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
@@ -272,7 +360,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
) {
|
) {
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
final expandedHeight = _calculateExpandedHeight(context);
|
||||||
final tracks = _tracks ?? [];
|
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;
|
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
@@ -505,7 +597,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
// Info is now displayed in the full-screen cover overlay
|
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,9 +610,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
final track = tracks[index];
|
final track = tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _AlbumTrackItem(
|
child: StaggeredListItem(
|
||||||
track: track,
|
index: index,
|
||||||
onDownload: () => _downloadTrack(context, track),
|
child: _AlbumTrackItem(
|
||||||
|
track: track,
|
||||||
|
onDownload: () => _downloadTrack(context, track),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: tracks.length),
|
}, childCount: tracks.length),
|
||||||
@@ -536,6 +630,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
artistName: track.artistName,
|
artistName: track.artistName,
|
||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
@@ -560,37 +655,82 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
void _downloadAll(BuildContext context) {
|
void _downloadAll(BuildContext context) {
|
||||||
final tracks = _tracks;
|
final tracks = _tracks;
|
||||||
if (tracks == null || tracks.isEmpty) return;
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
|
|
||||||
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
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) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
trackName: '${tracks.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: widget.albumName,
|
artistName: widget.albumName,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
.addMultipleToQueue(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
tracksToQueue,
|
||||||
SnackBar(
|
service,
|
||||||
content: Text(
|
qualityOverride: quality,
|
||||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
);
|
||||||
),
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, settings.defaultService);
|
.addMultipleToQueue(tracksToQueue, settings.defaultService);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||||
SnackBar(
|
|
||||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
Widget _buildLoveAllButton() {
|
||||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||||
final tracks = _tracks;
|
final tracks = _tracks;
|
||||||
@@ -619,7 +759,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
size: 22,
|
size: 22,
|
||||||
color: allLoved ? Colors.redAccent : Colors.white,
|
color: allLoved ? Colors.redAccent : Colors.white,
|
||||||
),
|
),
|
||||||
tooltip: allLoved ? 'Remove from Loved' : 'Love All',
|
tooltip: allLoved
|
||||||
|
? context.l10n.trackOptionRemoveFromLoved
|
||||||
|
: context.l10n.tooltipLoveAll,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -642,7 +784,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
? null
|
? null
|
||||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
||||||
icon: const Icon(Icons.add, size: 22, color: Colors.white),
|
icon: const Icon(Icons.add, size: 22, color: Colors.white),
|
||||||
tooltip: 'Add to Playlist',
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -660,7 +802,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Removed ${tracks.length} tracks from Loved')),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -673,7 +819,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Added $addedCount tracks to Loved')),
|
SnackBar(
|
||||||
|
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+238
-77
@@ -14,11 +14,13 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.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/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||||
show ExtensionAlbumScreen;
|
show ExtensionAlbumScreen;
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||||
|
|
||||||
class _ArtistCache {
|
class _ArtistCache {
|
||||||
@@ -38,12 +40,14 @@ class _ArtistCache {
|
|||||||
static void set(
|
static void set(
|
||||||
String artistId, {
|
String artistId, {
|
||||||
required List<ArtistAlbum> albums,
|
required List<ArtistAlbum> albums,
|
||||||
|
List<ArtistAlbum>? releases,
|
||||||
List<Track>? topTracks,
|
List<Track>? topTracks,
|
||||||
String? headerImageUrl,
|
String? headerImageUrl,
|
||||||
int? monthlyListeners,
|
int? monthlyListeners,
|
||||||
}) {
|
}) {
|
||||||
_cache[artistId] = _CacheEntry(
|
_cache[artistId] = _CacheEntry(
|
||||||
albums: albums,
|
albums: albums,
|
||||||
|
releases: releases,
|
||||||
topTracks: topTracks,
|
topTracks: topTracks,
|
||||||
headerImageUrl: headerImageUrl,
|
headerImageUrl: headerImageUrl,
|
||||||
monthlyListeners: monthlyListeners,
|
monthlyListeners: monthlyListeners,
|
||||||
@@ -54,6 +58,7 @@ class _ArtistCache {
|
|||||||
|
|
||||||
class _CacheEntry {
|
class _CacheEntry {
|
||||||
final List<ArtistAlbum> albums;
|
final List<ArtistAlbum> albums;
|
||||||
|
final List<ArtistAlbum>? releases;
|
||||||
final List<Track>? topTracks;
|
final List<Track>? topTracks;
|
||||||
final String? headerImageUrl;
|
final String? headerImageUrl;
|
||||||
final int? monthlyListeners;
|
final int? monthlyListeners;
|
||||||
@@ -61,6 +66,7 @@ class _CacheEntry {
|
|||||||
|
|
||||||
_CacheEntry({
|
_CacheEntry({
|
||||||
required this.albums,
|
required this.albums,
|
||||||
|
this.releases,
|
||||||
this.topTracks,
|
this.topTracks,
|
||||||
this.headerImageUrl,
|
this.headerImageUrl,
|
||||||
this.monthlyListeners,
|
this.monthlyListeners,
|
||||||
@@ -97,6 +103,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
|||||||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||||
bool _isLoadingDiscography = false;
|
bool _isLoadingDiscography = false;
|
||||||
List<ArtistAlbum>? _albums;
|
List<ArtistAlbum>? _albums;
|
||||||
|
List<ArtistAlbum>? _releases;
|
||||||
List<Track>? _topTracks;
|
List<Track>? _topTracks;
|
||||||
String? _headerImageUrl;
|
String? _headerImageUrl;
|
||||||
int? _monthlyListeners;
|
int? _monthlyListeners;
|
||||||
@@ -104,6 +111,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
|
|
||||||
bool _showTitleInAppBar = false;
|
bool _showTitleInAppBar = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final PageController _popularPageController = PageController();
|
||||||
|
int _popularCurrentPage = 0;
|
||||||
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedAlbumIds = {};
|
final Set<String> _selectedAlbumIds = {};
|
||||||
@@ -144,6 +153,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return tileSize + 64 + ((textScale - 1) * 14);
|
return tileSize + 64 + ((textScale - 1) * 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _recommendedDownloadService() {
|
||||||
|
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||||
|
return widget.extensionId;
|
||||||
|
}
|
||||||
|
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||||
|
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||||
|
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -153,7 +172,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final providerId =
|
final providerId =
|
||||||
widget.extensionId ??
|
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
|
ref
|
||||||
.read(recentAccessProvider.notifier)
|
.read(recentAccessProvider.notifier)
|
||||||
.recordArtistAccess(
|
.recordArtistAccess(
|
||||||
@@ -169,6 +193,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
_topTracks = widget.topTracks;
|
_topTracks = widget.topTracks;
|
||||||
_headerImageUrl = widget.headerImageUrl;
|
_headerImageUrl = widget.headerImageUrl;
|
||||||
_monthlyListeners = widget.monthlyListeners;
|
_monthlyListeners = widget.monthlyListeners;
|
||||||
|
|
||||||
|
if ((_albums == null || _albums!.isEmpty) ||
|
||||||
|
(_topTracks == null || _topTracks!.isEmpty)) {
|
||||||
|
_fetchDiscography();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +214,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
}
|
}
|
||||||
} else if (cached != null) {
|
} else if (cached != null) {
|
||||||
_albums = cached.albums;
|
_albums = cached.albums;
|
||||||
|
_releases = cached.releases;
|
||||||
_topTracks = cached.topTracks;
|
_topTracks = cached.topTracks;
|
||||||
_headerImageUrl = cached.headerImageUrl;
|
_headerImageUrl = cached.headerImageUrl;
|
||||||
_monthlyListeners = cached.monthlyListeners;
|
_monthlyListeners = cached.monthlyListeners;
|
||||||
@@ -209,6 +239,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController.removeListener(_onScroll);
|
_scrollController.removeListener(_onScroll);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
_popularPageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +247,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
setState(() => _isLoadingDiscography = true);
|
setState(() => _isLoadingDiscography = true);
|
||||||
try {
|
try {
|
||||||
List<ArtistAlbum> albums;
|
List<ArtistAlbum> albums;
|
||||||
|
List<ArtistAlbum>? releases;
|
||||||
List<Track>? topTracks;
|
List<Track>? topTracks;
|
||||||
String? headerImage;
|
String? headerImage;
|
||||||
int? listeners;
|
int? listeners;
|
||||||
@@ -230,6 +262,65 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
albums = albumsList
|
albums = albumsList
|
||||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||||
.toList();
|
.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 {
|
} else {
|
||||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
@@ -252,13 +343,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
headerImage = artistData['header_image'] as String?;
|
headerImage = artistData['header_image'] as String?;
|
||||||
listeners = artistData['listeners'] as int?;
|
listeners = artistData['listeners'] as int?;
|
||||||
} else {
|
} else {
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(
|
throw StateError('Failed to load artist metadata from extension');
|
||||||
url,
|
|
||||||
);
|
|
||||||
final albumsList = metadata['albums'] as List<dynamic>;
|
|
||||||
albums = albumsList
|
|
||||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +355,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
_ArtistCache.set(
|
_ArtistCache.set(
|
||||||
widget.artistId,
|
widget.artistId,
|
||||||
albums: albums,
|
albums: albums,
|
||||||
|
releases: releases,
|
||||||
topTracks: topTracks,
|
topTracks: topTracks,
|
||||||
headerImageUrl: finalHeaderImage,
|
headerImageUrl: finalHeaderImage,
|
||||||
monthlyListeners: finalListeners,
|
monthlyListeners: finalListeners,
|
||||||
@@ -278,6 +364,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_albums = albums;
|
_albums = albums;
|
||||||
|
_releases = releases;
|
||||||
_topTracks = topTracks;
|
_topTracks = topTracks;
|
||||||
_headerImageUrl = finalHeaderImage;
|
_headerImageUrl = finalHeaderImage;
|
||||||
_monthlyListeners = finalListeners;
|
_monthlyListeners = finalListeners;
|
||||||
@@ -303,8 +390,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
durationMs = durationValue.toInt();
|
durationMs = durationValue.toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||||
|
final nativeId = (data['id'] ?? '').toString();
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
|
||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
|
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
|
||||||
@@ -314,8 +404,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||||
widget.artistId,
|
widget.artistId,
|
||||||
albumId: data['album_id']?.toString() ?? album?.id,
|
albumId: data['album_id']?.toString() ?? album?.id,
|
||||||
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl)
|
coverUrl: normalizeCoverReference(
|
||||||
?.toString(),
|
(data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(),
|
||||||
|
),
|
||||||
isrc: data['isrc']?.toString(),
|
isrc: data['isrc']?.toString(),
|
||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
@@ -323,20 +414,28 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
albumType: data['album_type']?.toString() ?? album?.albumType,
|
albumType: data['album_type']?.toString() ?? album?.albumType,
|
||||||
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
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) {
|
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||||
|
final totalTracksValue = data['total_tracks'];
|
||||||
|
final totalTracks = totalTracksValue is int
|
||||||
|
? totalTracksValue
|
||||||
|
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
|
||||||
|
|
||||||
return ArtistAlbum(
|
return ArtistAlbum(
|
||||||
id: data['id'] as String? ?? '',
|
id: data['id'] as String? ?? '',
|
||||||
name: data['name'] as String? ?? '',
|
name: (data['name'] ?? data['title'] ?? '').toString(),
|
||||||
releaseDate: data['release_date'] as String? ?? '',
|
releaseDate: (data['release_date'] ?? '').toString(),
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
totalTracks: totalTracks,
|
||||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
coverUrl: normalizeCoverReference(
|
||||||
albumType: data['album_type'] as String? ?? 'album',
|
(data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(),
|
||||||
artists: data['artists'] as String? ?? '',
|
),
|
||||||
providerId: data['provider_id']?.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 +458,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final albums = _albums ?? [];
|
final albums = _albums ?? [];
|
||||||
_ensureAlbumBuckets(albums);
|
_ensureAlbumBuckets(albums);
|
||||||
|
final releases = _releases ?? const <ArtistAlbum>[];
|
||||||
final albumsOnly = _albumsOnlyBucket;
|
final albumsOnly = _albumsOnlyBucket;
|
||||||
final singles = _singlesBucket;
|
final singles = _singlesBucket;
|
||||||
final compilations = _compilationsBucket;
|
final compilations = _compilationsBucket;
|
||||||
@@ -386,10 +486,17 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
hasDiscography: hasDiscography,
|
hasDiscography: hasDiscography,
|
||||||
),
|
),
|
||||||
if (_isLoadingDiscography)
|
if (_isLoadingDiscography)
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: ArtistScreenSkeleton(
|
||||||
padding: EdgeInsets.all(32),
|
showCoverHeader:
|
||||||
child: Center(child: CircularProgressIndicator()),
|
(_headerImageUrl ??
|
||||||
|
widget.headerImageUrl ??
|
||||||
|
widget.coverUrl) ==
|
||||||
|
null,
|
||||||
|
showPopularSection:
|
||||||
|
!widget.artistId.startsWith('deezer:') &&
|
||||||
|
!widget.artistId.startsWith('qobuz:') &&
|
||||||
|
!widget.artistId.startsWith('tidal:'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_error != null)
|
if (_error != null)
|
||||||
@@ -404,6 +511,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _buildPopularSection(colorScheme),
|
child: _buildPopularSection(colorScheme),
|
||||||
),
|
),
|
||||||
|
if (releases.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _buildAlbumSection(
|
||||||
|
'Releases',
|
||||||
|
releases,
|
||||||
|
colorScheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (albumsOnly.isNotEmpty)
|
if (albumsOnly.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _buildAlbumSection(
|
child: _buildAlbumSection(
|
||||||
@@ -684,7 +799,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
);
|
);
|
||||||
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
@@ -786,6 +901,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
_fetchAndQueueAlbums(albums, service, quality);
|
_fetchAndQueueAlbums(albums, service, quality);
|
||||||
},
|
},
|
||||||
@@ -817,7 +933,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (ctx) => _FetchingProgressDialog(
|
builder: (ctx) => _FetchingProgressDialog(
|
||||||
@@ -845,7 +961,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
|
|
||||||
fetchedCount++;
|
fetchedCount++;
|
||||||
|
|
||||||
// Update progress dialog
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_FetchingProgressDialog.updateProgress(
|
_FetchingProgressDialog.updateProgress(
|
||||||
context,
|
context,
|
||||||
@@ -876,7 +991,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check which tracks are already downloaded
|
|
||||||
final historyState = ref.read(downloadHistoryProvider);
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final tracksToQueue = <Track>[];
|
final tracksToQueue = <Track>[];
|
||||||
int skippedCount = 0;
|
int skippedCount = 0;
|
||||||
@@ -927,10 +1041,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
content: Text(message),
|
content: Text(message),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: context.l10n.snackbarViewQueue,
|
label: context.l10n.snackbarViewQueue,
|
||||||
onPressed: () {
|
onPressed: () {},
|
||||||
// Navigate to queue tab (index 1)
|
|
||||||
// This will be handled by the navigation system
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -961,6 +1072,24 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album))
|
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album))
|
||||||
.toList();
|
.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 {
|
} else {
|
||||||
final url = 'https://open.spotify.com/album/${album.id}';
|
final url = 'https://open.spotify.com/album/${album.id}';
|
||||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||||
@@ -970,15 +1099,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to direct Spotify metadata
|
|
||||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
|
||||||
if (metadata['tracks'] != null) {
|
|
||||||
final tracksList = metadata['tracks'] as List<dynamic>;
|
|
||||||
return tracksList
|
|
||||||
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -986,6 +1106,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
|
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
|
||||||
int durationMs = 0;
|
int durationMs = 0;
|
||||||
final durationValue = data['duration'];
|
final durationValue = data['duration'];
|
||||||
|
final artistData = data['artist'];
|
||||||
|
final artistName = artistData is Map<String, dynamic>
|
||||||
|
? (artistData['name'] as String? ?? widget.artistName)
|
||||||
|
: (artistData?.toString() ?? widget.artistName);
|
||||||
if (durationValue is int) {
|
if (durationValue is int) {
|
||||||
durationMs = durationValue * 1000; // Deezer returns seconds
|
durationMs = durationValue * 1000; // Deezer returns seconds
|
||||||
} else if (durationValue is double) {
|
} else if (durationValue is double) {
|
||||||
@@ -995,9 +1119,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return Track(
|
return Track(
|
||||||
id: 'deezer:${data['id']}',
|
id: 'deezer:${data['id']}',
|
||||||
name: (data['title'] ?? data['name'] ?? '').toString(),
|
name: (data['title'] ?? data['name'] ?? '').toString(),
|
||||||
artistName:
|
artistName: artistName,
|
||||||
(data['artist']?['name'] ?? data['artist'] ?? widget.artistName)
|
|
||||||
.toString(),
|
|
||||||
albumName: album.name,
|
albumName: album.name,
|
||||||
albumArtist: widget.artistName,
|
albumArtist: widget.artistName,
|
||||||
artistId: widget.artistId,
|
artistId: widget.artistId,
|
||||||
@@ -1033,6 +1155,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
imageUrl.isNotEmpty &&
|
imageUrl.isNotEmpty &&
|
||||||
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
||||||
|
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
String? listenersText;
|
String? listenersText;
|
||||||
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
||||||
if (listeners != null && listeners > 0) {
|
if (listeners != null && listeners > 0) {
|
||||||
@@ -1103,7 +1227,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
Colors.black.withValues(alpha: 0.3),
|
Colors.black.withValues(alpha: 0.3),
|
||||||
Colors.black.withValues(alpha: 0.7),
|
Colors.black.withValues(alpha: 0.7),
|
||||||
colorScheme.surface,
|
isDark
|
||||||
|
? colorScheme.surface
|
||||||
|
: Colors.black.withValues(alpha: 0.85),
|
||||||
],
|
],
|
||||||
stops: const [0.0, 0.5, 0.75, 1.0],
|
stops: const [0.0, 0.5, 0.75, 1.0],
|
||||||
),
|
),
|
||||||
@@ -1144,7 +1270,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
listenersText,
|
listenersText,
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
color: Colors.white,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(
|
Shadow(
|
||||||
offset: const Offset(0, 1),
|
offset: const Offset(0, 1),
|
||||||
@@ -1211,7 +1337,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final tracks = _topTracks!.take(5).toList();
|
final tracks = _topTracks!;
|
||||||
|
const tracksPerPage = 5;
|
||||||
|
final pageCount = (tracks.length / tracksPerPage).ceil();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1225,11 +1353,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...tracks.asMap().entries.map((entry) {
|
SizedBox(
|
||||||
final index = entry.key;
|
height: tracksPerPage * 64.0,
|
||||||
final track = entry.value;
|
child: PageView.builder(
|
||||||
return _buildPopularTrackItem(index + 1, track, colorScheme);
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1517,6 +1694,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
|
recommendedService: _recommendedDownloadService(),
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
enqueue(service, quality: quality);
|
enqueue(service, quality: quality);
|
||||||
@@ -1667,29 +1845,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: AnimatedContainer(
|
child: AnimatedSelectionCheckbox(
|
||||||
duration: const Duration(milliseconds: 200),
|
visible: true,
|
||||||
width: 28,
|
selected: isSelected,
|
||||||
height: 28,
|
colorScheme: colorScheme,
|
||||||
decoration: BoxDecoration(
|
size: 28,
|
||||||
color: isSelected
|
unselectedColor: colorScheme.surface.withValues(
|
||||||
? colorScheme.primary
|
alpha: 0.9,
|
||||||
: colorScheme.surface.withValues(alpha: 0.9),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 18,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showTypeBadge)
|
if (showTypeBadge)
|
||||||
@@ -1762,7 +1925,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => ExtensionAlbumScreen(
|
builder: (context) => ExtensionAlbumScreen(
|
||||||
extensionId: album.providerId!,
|
extensionId: album.providerId!,
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -1774,7 +1937,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||||||
} else {
|
} else {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => AlbumScreen(
|
builder: (context) => AlbumScreen(
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
albumName: album.name,
|
albumName: album.name,
|
||||||
@@ -1898,7 +2061,6 @@ class _FetchingProgressDialog extends StatefulWidget {
|
|||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Static method to update progress from outside
|
|
||||||
static void updateProgress(BuildContext context, int current, int total) {
|
static void updateProgress(BuildContext context, int current, int total) {
|
||||||
final state = context
|
final state = context
|
||||||
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
||||||
@@ -1971,7 +2133,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Progress bar
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||||
|
|
||||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||||
final String albumName;
|
final String albumName;
|
||||||
@@ -120,17 +122,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
final tracks =
|
final tracks =
|
||||||
allItems.where((item) {
|
allItems.where((item) {
|
||||||
// Use albumArtist if available and not empty, otherwise artistName
|
|
||||||
final itemArtist =
|
final itemArtist =
|
||||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||||
? item.albumArtist!
|
? item.albumArtist!
|
||||||
: item.artistName;
|
: item.artistName;
|
||||||
// Use lowercase for case-insensitive matching
|
|
||||||
final itemKey =
|
final itemKey =
|
||||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||||
return itemKey == _albumLookupKey;
|
return itemKey == _albumLookupKey;
|
||||||
}).toList()..sort((a, b) {
|
}).toList()..sort((a, b) {
|
||||||
// Sort by disc number first, then by track number
|
|
||||||
final aDisc = a.discNumber ?? 1;
|
final aDisc = a.discNumber ?? 1;
|
||||||
final bDisc = b.discNumber ?? 1;
|
final bDisc = b.discNumber ?? 1;
|
||||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||||
@@ -311,14 +310,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final result = await navigator.push(
|
final result = await navigator.push(
|
||||||
PageRouteBuilder(
|
slidePageRoute<bool>(page: TrackMetadataScreen(item: item)),
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
TrackMetadataScreen(item: item),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
|
||||||
FadeTransition(opacity: animation, child: child),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||||
item.filePath,
|
item.filePath,
|
||||||
@@ -363,7 +355,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
if (tracks.isEmpty) {
|
if (tracks.isEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.albumName)),
|
appBar: AppBar(title: Text(widget.albumName)),
|
||||||
body: Center(child: Text('No tracks found for this album')),
|
body: Center(child: Text(context.l10n.noTracksFoundForAlbum)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,7 +686,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final track = tracks[index];
|
final track = tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: StaggeredListItem(
|
||||||
|
index: index,
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}, childCount: tracks.length),
|
}, childCount: tracks.length),
|
||||||
);
|
);
|
||||||
@@ -702,6 +697,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
final discNumbers = _getSortedDiscNumbers(tracks);
|
final discNumbers = _getSortedDiscNumbers(tracks);
|
||||||
final List<Widget> children = [];
|
final List<Widget> children = [];
|
||||||
|
var revealIndex = 0;
|
||||||
|
|
||||||
for (final discNumber in discNumbers) {
|
for (final discNumber in discNumbers) {
|
||||||
final discTracks = discMap[discNumber];
|
final discTracks = discMap[discNumber];
|
||||||
@@ -713,7 +709,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children.add(
|
children.add(
|
||||||
KeyedSubtree(
|
KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: StaggeredListItem(
|
||||||
|
index: revealIndex++,
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -797,28 +796,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (_isSelectionMode) ...[
|
if (_isSelectionMode) ...[
|
||||||
Container(
|
AnimatedSelectionCheckbox(
|
||||||
width: 24,
|
visible: true,
|
||||||
height: 24,
|
selected: isSelected,
|
||||||
decoration: BoxDecoration(
|
colorScheme: colorScheme,
|
||||||
color: isSelected
|
size: 24,
|
||||||
? colorScheme.primary
|
|
||||||
: Colors.transparent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primary
|
|
||||||
: colorScheme.outline,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isSelected
|
|
||||||
? Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 16,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
@@ -911,10 +893,47 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<DownloadHistoryItem> allTracks,
|
List<DownloadHistoryItem> allTracks,
|
||||||
) {
|
) {
|
||||||
String selectedFormat = 'MP3';
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
String selectedBitrate = '320k';
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
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<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
@@ -924,7 +943,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setSheetState) {
|
builder: (context, setSheetState) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final formats = ['MP3', 'Opus'];
|
|
||||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -961,51 +979,73 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: bitrates.map((br) {
|
children: formats.map((format) {
|
||||||
final isSelected = br == selectedBitrate;
|
final isSelected = format == selectedFormat;
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(br),
|
label: Text(format),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (selected) {
|
onSelected: (selected) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setSheetState(() => selectedBitrate = br);
|
setSheetState(() {
|
||||||
|
selectedFormat = format;
|
||||||
|
isLosslessTarget =
|
||||||
|
format == 'ALAC' || format == 'FLAC';
|
||||||
|
if (!isLosslessTarget) {
|
||||||
|
selectedBitrate = format == 'Opus'
|
||||||
|
? '128k'
|
||||||
|
: '320k';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).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),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -1058,12 +1098,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
: item.filePath.toLowerCase();
|
: item.filePath.toLowerCase();
|
||||||
final ext = nameToCheck.endsWith('.flac')
|
final ext = nameToCheck.endsWith('.flac')
|
||||||
? 'FLAC'
|
? 'FLAC'
|
||||||
|
: nameToCheck.endsWith('.m4a')
|
||||||
|
? 'M4A'
|
||||||
: nameToCheck.endsWith('.mp3')
|
: nameToCheck.endsWith('.mp3')
|
||||||
? 'MP3'
|
? 'MP3'
|
||||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||||
? 'Opus'
|
? 'Opus'
|
||||||
: null;
|
: null;
|
||||||
if (ext != null && ext != targetFormat) selected.add(item);
|
if (ext == null || ext == targetFormat) continue;
|
||||||
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
|
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||||
|
if (isLosslessTarget && !isLosslessSource) continue;
|
||||||
|
selected.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.isEmpty) {
|
if (selected.isEmpty) {
|
||||||
@@ -1075,16 +1121,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
context.l10n.selectionBatchConvertConfirmMessage(
|
isLossless
|
||||||
selected.length,
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
targetFormat,
|
selected.length,
|
||||||
bitrate,
|
targetFormat,
|
||||||
),
|
)
|
||||||
|
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
|
selected.length,
|
||||||
|
targetFormat,
|
||||||
|
bitrate,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -1105,24 +1157,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final total = selected.length;
|
final total = selected.length;
|
||||||
final historyDb = HistoryDatabase.instance;
|
final historyDb = HistoryDatabase.instance;
|
||||||
final newQuality =
|
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 settings = ref.read(settingsProvider);
|
||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
|
|
||||||
|
var cancelled = false;
|
||||||
|
BatchProgressDialog.show(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.trackConvertConverting,
|
||||||
|
total: total,
|
||||||
|
icon: Icons.transform,
|
||||||
|
onCancel: () {
|
||||||
|
cancelled = true;
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
for (int i = 0; i < total; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
if (!mounted) break;
|
if (!mounted || cancelled) break;
|
||||||
final item = selected[i];
|
final item = selected[i];
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
context.l10n.selectionBatchConvertProgress(i + 1, total),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final metadata = <String, String>{
|
final metadata = <String, String>{
|
||||||
@@ -1133,12 +1192,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
try {
|
try {
|
||||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||||
if (result['error'] == null) {
|
if (result['error'] == null) {
|
||||||
result.forEach((key, value) {
|
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||||
if (key == 'error' || value == null) return;
|
|
||||||
final v = value.toString().trim();
|
|
||||||
if (v.isEmpty) return;
|
|
||||||
metadata[key.toUpperCase()] = v;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
await ensureLyricsMetadataForConversion(
|
await ensureLyricsMetadataForConversion(
|
||||||
@@ -1208,13 +1262,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final baseName = dotIdx > 0
|
final baseName = dotIdx > 0
|
||||||
? oldFileName.substring(0, dotIdx)
|
? oldFileName.substring(0, dotIdx)
|
||||||
: oldFileName;
|
: oldFileName;
|
||||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
String newExt;
|
||||||
? '.opus'
|
String mimeType;
|
||||||
: '.mp3';
|
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 newFileName = '$baseName$newExt';
|
||||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
|
||||||
? 'audio/opus'
|
|
||||||
: 'audio/mpeg';
|
|
||||||
|
|
||||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
treeUri: treeUri,
|
treeUri: treeUri,
|
||||||
@@ -1272,6 +1340,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
_exitSelectionMode();
|
_exitSelectionMode();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
if (!cancelled) {
|
||||||
|
BatchProgressDialog.dismiss(context);
|
||||||
|
}
|
||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user